Next.js App Router
Data Fetching and Caching in the App Router
Fetching in Server Components, parallel versus sequential requests, the caching layers, revalidation, and the current Next.js caching model from the bundled docs.
On this page
- Words used in this lesson
- Fetching in a Server Component
- Same request twice: memoization
- Waterfalls, parallel requests and Suspense
- Is my page static or dynamic? The default model
- The new model: Cache Components
- Revalidation: refreshing what is cached
- The layers: what is cached where
- Which model should I use?
- A decision list for a data-reading component
- Common mistakes
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: In the App Router you fetch data by making a Server Component async and using await right inside it. No useEffect, no API route in between. The hard part is not the fetching. It is knowing when your data is fetched (at build, at each request, or never again), and how to tell Next.js to refresh it. This version of Next.js has two caching models: the older one, where a page is static unless it reads request data, and the new Cache Components model, where nothing is cached until you write use cache. This lesson runs both and shows the real numbers.
If you come from React, you learned to fetch in useEffect, keep loading and error state, and worry about race conditions. In a Server Component all of that goes away. The component waits for the data, then returns HTML. But you inherit a new question: if 1,000 people open this page, does it fetch 1,000 times? The answer depends on a few simple rules, and we test each one.
This is lesson 2 of ten. Lesson 1 taught what runs on the server and what runs in the browser. Here we use that to fetch data. The next lesson, Server Actions, Forms and Mutations, is the other half: changing data and then refreshing what the caches hold. For the browser-side view of fetching (loading flags, races, TanStack Query), see Data Fetching, Caching and Server State.
What I ran / checked. Everything ran on Linux with Node.js v22.22.0, Next.js 16.3.5 (this repo's version), Turbopack, React 19.3.0 and TypeScript 7.0.2. I wrote a tiny local "backend" in plain Node that counts every request it gets and can delay its answer on demand. Then I built two throwaway apps against it, one with the default settings and one with cacheComponents: true, ran next build and next start, and used curl to ask for pages and read the server's request counter. Sources I read (in my own words below): inside node_modules/next/dist/docs/01-app/: "Fetching data", "Caching" (the Cache Components page), "Revalidating", "Mutating data", "Server Actions and mutations", the older-model guide "Caching and Revalidating (Previous Model)", and the API pages for use cache, cacheLife, revalidateTag and cacheComponents. Not verified: I did not open nextjs.org. I did not test use cache: remote, use cache: private, or a real database. I did not test serverless hosting, where in-memory cache rules change (I report what the docs say). Timings are from one machine and will vary.
Words used in this lesson
- Memoization (request memoization) means that if the same
fetch(same URL and options) is called more than once while one page renders, only one real request is sent. - Waterfall means requests that wait for each other one by one, when they could have run at the same time.
- Parallel fetching means starting independent requests together, for example with
Promise.all. - Prerendering means building the HTML before any user asks, at build time.
- Cache is a stored answer, so the work is not done again.
- Revalidation means refreshing a cached answer.
- Stale means "old". Stale-while-revalidate means you serve the old answer now and fetch a new one in the background.
- Cache Components is the new model, switched on by
cacheComponents: trueinnext.config.ts. use cacheis a directive. Put it at the top of an async function or component to cache what it returns.cacheLifesets how long ause cacheresult stays fresh.cacheTagputs a label on a cached result so you can refresh it later by that label.- Static shell is the part of a page that is built ahead of time (static content, cached content and fallbacks).
- Partial Prerendering (PPR) means one page has a static shell plus parts that stream in at request time. In the build table it shows as
◐. - Runtime data is data that only exists when a request arrives: cookies, headers,
searchParams, andparamsthat were not listed ingenerateStaticParams.
Fetching in a Server Component
A Server Component can be async. You await the data and return JSX:
// app/posts/page.tsxexport default async function Page() { const res = await fetch('https://api.example.com/posts') const posts = await res.json() return ( <ul> {posts.map((p: { id: number; title: string }) => ( <li key={p.id}>{p.title}</li> ))} </ul> )}The docs say you can use any async source here: the built-in fetch, or an ORM or a database client. Because the code runs on the server, the connection string and the query never reach the browser (the boundary rules from lesson 1 apply).
import { db, posts } from '@/lib/db'export default async function Page() { const all = await db.select().from(posts) return <ul>{all.map(p => <li key={p.id}>{p.title}</li>)}</ul>}Compare that with the React way: no useState for loading, no useEffect, no cleanup to avoid races. The user gets HTML that already holds the data. For a Client Component that really needs to fetch (live data after a click, for example), the docs suggest a library like SWR or TanStack Query, or the use hook with a promise from the server (lesson 1).
Authorize inside the data function. A page being "behind login" is not enough. The docs repeat this advice: check authentication and authorization where the data is read. Lesson 5 covers it.
My test bench
To see what happens behind the scenes, I made a small server on port 4000. Every answer has a hit number and a time. A special URL tells me how many real requests it has received:
// api/server.mjs (shortened)import http from 'node:http'let hits = 0http.createServer(async (req, res) => { const u = new URL(req.url, 'http://x') if (u.pathname === '/__hits') return res.end(JSON.stringify({ hits })) const n = ++hits const delay = Number(u.searchParams.get('delay') || 0) if (delay) await new Promise(r => setTimeout(r, delay)) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ path: u.pathname, hit: n, at: new Date().toISOString().slice(11, 23) }))}).listen(4000)If a page shows hit 3, then it got the third answer of this server. If the number does not change between visits, no new request was sent. That is a clean way to measure caching.
Same request twice: memoization
Components should fetch the data they need, where they need it. That means a page and a child may both ask for the same thing. Next.js handles this: identical fetch calls (same URL and options) during one render are memoized. Only one real request goes out.
For things that are not fetch (a database call, say), you wrap the function with React's cache:
// lib/db.tsimport { cache } from 'react'export const calls = { plain: 0, cached: 0 }async function slowUser(kind: 'plain' | 'cached') { calls[kind]++ // count real executions await new Promise(r => setTimeout(r, 100)) // pretend: a database return { name: 'Asha' }}export const getUserPlain = () => slowUser('plain')export const getUserCached = cache(() => slowUser('cached'))Two components A and B each did fetch('/memo'), getUserPlain() and getUserCached(). A third component C did the fetch again after both finished and printed the counters. One request to the page gave:
A saw hit 1B saw hit 1C saw hit 1. plain DB calls: 2, cache() DB calls: 1API log: ["1 /memo"]The API server received one request for three identical fetch calls. The plain database function ran twice (once for A, once for B), and the one wrapped in cache() ran once. So: use fetch and memoization is free. For an ORM, wrap the read in cache().
This memoization lasts only for one render. It is not a cache between visitors. The next request starts from zero. For sharing across requests, you need real caching, which is the second half of this lesson.
Waterfalls, parallel requests and Suspense
Because await pauses the component, putting several awaits in a row makes a waterfall. I made an API that waits 500 ms before answering, and called it three times in three ways.
Sequential (a waterfall):
const a = await get(1)const b = await get(2)const c = await get(3)Parallel:
const [a, b, c] = await Promise.all([get(1), get(2), get(3)])Streaming with Suspense (each box fetches on its own, with delays of 400, 800 and 1200 ms):
<Suspense fallback={<p>loading 1</p>}><Item n={1} /></Suspense><Suspense fallback={<p>loading 2</p>}><Item n={2} /></Suspense><Suspense fallback={<p>loading 3</p>}><Item n={3} /></Suspense>I measured each with curl against next start, three runs each. Here is the real output:
seq total 1.669s first byte 1.669sseq total 1.528s first byte 1.527sseq total 1.521s first byte 1.520spar total 0.522s first byte 0.521spar total 0.512s first byte 0.512spar total 0.510s first byte 0.510ssusp total 1.211s first byte 0.011ssusp total 1.211s first byte 0.010ssusp total 1.212s first byte 0.017sHow to read it:
- Sequential waited 3 times 500 ms = about 1.5 s before sending anything.
- Parallel took about 0.5 s, the time of the slowest single request. Same work, one third of the time. If requests do not depend on each other, start them together.
- Streaming sent the first byte after about 10 ms (the heading and three "loading" boxes). The page finished at 1.2 s, the time of the slowest box, and each box appeared as soon as its own data was ready.
Note that the docs also describe a case where sequential is correct: when the second request needs a value from the first (an artist's id, then their playlists). Even then, put the second part in its own <Suspense> so the rest of the page does not wait.
The waterfall you do not see. A parent component that awaits data and then renders a child that also awaits data creates a waterfall, even though no single line looks wrong. In the profile, look for requests that start only after another one ends. Fix it by moving the fetches up and starting them together, by passing a promise down, or by giving each part its own Suspense boundary.
Is my page static or dynamic? The default model
Now the important part. Is a page fetched once, or on every visit? In this version of Next.js there are two answers, depending on one switch in next.config.ts. This repo does not set cacheComponents, so it uses the first model. I call it the default model here (the docs call it the "previous model").
I made five pages, each with one fetch to my counting server, and ran next build. The server's log after the build was:
["1 /forced","2 /plain","3 /timed"]Three of the five pages already hit the API during the build. The route table says why:
Route (app) Revalidate Expire┌ ○ /_not-found├ ƒ /afterheaders├ ○ /forced├ ƒ /nostore├ ○ /plain└ ○ /timed 3s 1yThe five pages were:
| Page | How it fetches | Result in the table |
|---|---|---|
/plain | fetch(url) with no options | ○ static |
/forced | fetch(url, { cache: 'force-cache' }) | ○ static |
/timed | fetch(url, { next: { revalidate: 3 } }) | ○ static, Revalidate 3s |
/nostore | fetch(url, { cache: 'no-store' }) | ƒ dynamic |
/afterheaders | reads headers() first, then fetch(url) | ƒ dynamic |
Surprise: /plain, a plain fetch with no options, is static. The docs explain it in the older-model guide: fetch is not cached by default, but a page that uses no request data (no cookies, headers, searchParams) is prerendered at build time. So the fetch runs once during the build, and its result is frozen into the page. A plain fetch that comes after a request-time API (like headers()) runs on every request.
Then I started the server and asked each page twice. Here is what came back (the last number on each line is the API hit number):
plain: hit 2 at 03:12:36.871 (same on request 2)forced: hit 1 at 03:12:36.857 (same on request 2)nostore: hit 4 at 03:12:44.943 / hit 5 at 03:12:44.979 (changes every time)afterheaders: hit 7 / hit 8 (changes every time)timed: hit 3 at 03:12:36.871 (same on request 2)plain and forced never changed. They were built once and served as files. nostore and afterheaders called the API on every request.
timed is the interesting one. It had revalidate: 3. I asked for it twice right after the other pages (about 8 seconds after the build), then waited 4 seconds and asked three more times, one per second. The five answers were:
hit 3 at 03:12:36.871 <- built copy, now more than 3 s oldhit 3 at 03:12:36.871 <- still the built copy (this request started a background refresh)hit 6 at 03:12:45.016 <- the refreshed copyhit 9 at 49.105 <- the refresh that the previous request startedhit 9 at 49.105The rule: the first request after the time ran out still got the old page, and started a refresh in the background. The next request got the new page. This is stale-while-revalidate. Nobody waits for the refresh. The cost is that one user can see data that is a little old.
The trap of the default model. You write a normal fetch in a normal component and next build quietly turns it into a static page with old data. The reverse also happens: one cookies() or headers() call makes the whole route dynamic. Always read the route table after a build, and do not guess.
Controls in the default model
You can force the behaviour:
fetch(url, { cache: 'force-cache' })caches that request.fetch(url, { cache: 'no-store' })never caches it.fetch(url, { next: { revalidate: 60 } })keeps it for 60 seconds, then refreshes (stale-while-revalidate).fetch(url, { next: { tags: ['news'] } })labels it for on-demand refresh.unstable_cache(fn, keys, { tags, revalidate })does the same for non-fetchfunctions such as a database query.- Route segment config:
export const dynamic = 'force-dynamic'(or'force-static') andexport const revalidate = 60on a page, layout or route handler.
The revalidate value must be a plain number in the source. 60 * 10 is not accepted, according to the docs. And in next dev, pages are always rendered on demand, so dev never shows you the caching behaviour. Use build and start to test.
The new model: Cache Components
The newer model flips the default. You turn it on in the config:
// next.config.tsimport type { NextConfig } from 'next'const nextConfig: NextConfig = { cacheComponents: true }export default nextConfigThe docs describe the idea in one line: data fetching is dynamic by default, and you choose what to cache, at the page, component or function level. Next.js builds a static shell and streams the dynamic parts in. (This mix is called Partial Prerendering, and it is the default behaviour with this flag. The old experimental.ppr flag is gone.)
Also, with cacheComponents on, Next.js uses React's <Activity> to keep a few recently visited pages hidden in the browser, so their state survives going back and forth.
Step 1: an uncached fetch must be handled
I switched the flag on and wrote the simplest page, a plain fetch in the page with nothing around it. next build refused:
Error: Route "/plain": Next.js encountered uncached or runtime data during prerendering.`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of`<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` - [block] Set `export const instant = false` to allow a blocking routeThis is a big change. The framework makes you decide. Each data read must be one of three things:
- Streamed (wrapped in
<Suspense>): it is fetched on each request, and the fallback is in the static shell. - Cached (
use cache): it is part of the static shell. - Blocking (opt out with
instant = false): the page waits. You pick this on purpose, not by accident.
Option A: stream it
// app/stream/page.tsximport { Suspense } from 'react'async function Live() { const d = await (await fetch(`${API}/stream?delay=800`)).json() return <p id="out">live: hit {d.hit} at {d.at}</p>}export default function Page() { return ( <main> <h1>Static heading</h1> <Suspense fallback={<p id="out">loading live data...</p>}> <Live /> </Suspense> </main> )}The route table has a new symbol:
├ ◐ /stream◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentI opened the built file .next/server/app/stream.html. It contains loading live data... and no live: hit. So the shell was made at build time, and the data is added at request time. With curl (production server, 800 ms delay in the API):
stream: first byte 0.072s, total 0.972sstream: first byte 0.012s, total 0.835sThe heading arrives at once, the data about 800 ms later. This matches the docs: the fallback ships with the shell and the async work runs at request time.
Option B: cache it with use cache
// app/cached/page.tsximport { cacheLife, cacheTag } from 'next/cache'async function getPosts() { 'use cache' cacheLife('hours') cacheTag('posts') const d = await (await fetch(`${API}/posts`)).json() return d as { hit: number; at: string }}export default async function Page() { const d = await getPosts() return <p id="out">cached: hit {d.hit} at {d.at}</p>}'use cache' goes at the top of an async function (data-level), or at the top of a component or page (UI-level), or at the top of a file (every export is cached). The docs recommend always pairing it with cacheLife. Without one, the default profile applies.
The route table:
├ ○ /cached 1h 1dIt is static (○), with a revalidate time of 1 hour and an expire time of 1 day. The response headers show the same thing:
x-nextjs-cache: HITx-nextjs-prerender: 1x-nextjs-stale-time: 300Cache-Control: s-maxage=3600, stale-while-revalidate=82800s-maxage=3600 means a CDN may keep it for an hour, then serve it stale for up to 23 more hours while it refreshes (the 1-day expiry, minus the first hour). I asked the page twice after next start: both showed the same hit number and time, and the API log still held only the single request from the build.
Cache keys
What if the cached function has an argument? The arguments (and any values it uses from the surrounding scope) become part of the cache key. So each input gets its own entry. I tested getPost(id) with use cache on the route /post/[id], and generateStaticParams returning only id = 1. I requested ids 1, 2, 2, 3, 3. The API log:
["1 /post-1","2 /timed","3 /posts","4 /post-2","5 /post-3"]post-1 was fetched at build. post-2 and post-3 were fetched on their first request only, and the second request for each came from the cache. In the route table, /post/1 is ○ and the general /post/[id] is ◐, meaning "a shell with the param-specific part left open".
cacheLife: how long is "fresh"?
cacheLife takes a preset name or an object. Presets from the docs:
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5m | 15m | never |
seconds | 30s | 1s | 60s |
minutes | 5m | 1m | 1h |
hours | 5m | 1h | 1d |
days | 5m | 1d | 1w |
weeks | 5m | 1w | 30d |
max | 5m | 30d | 1y |
The three numbers mean different things:
stale: how long the browser may use its own copy without asking the server (this feeds the client router).revalidate: after this time, the server refreshes the entry in the background (stale-while-revalidate).expire: after this time with no traffic, the next request must wait for a fresh result.
I set a custom lifetime to test: cacheLife({ stale: 60, revalidate: 3, expire: 600 }). The table showed Revalidate 3s, Expire 10m, and the behaviour matched the default model's timed page: the first request after 3 seconds got the old copy and triggered a new fetch.
A trap here. My first try used stale: 1, and the build failed with the same "uncached data" error as before. The docs explain: a lifetime that is too short cannot be part of the static shell. Specifically, revalidate of 0, or expire under 5 minutes, or stale under 30 seconds, turns that cache into a "dynamic hole" resolved at request time, and holes need <Suspense> around them. stale between 30 s and 5 min is allowed in the page but is left out of the reusable App Shell. Of the presets, only seconds is short enough to trigger this.
Runtime data cannot live inside use cache
Cookies, headers and searchParams are different for each visitor, so they cannot be read inside a cached scope. I tried:
async function getTheme() { 'use cache' const c = await cookies() // not allowed here return c.get('theme')?.value ?? 'light'}The build error was:
Error: Route /bad-cookie used `cookies()` inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported.If you need this data inside a cached function use `cookies()` outside of the cached function andpass the required dynamic data in as an argument.The fix is in the message. Read the cookie outside, in a component under <Suspense>, and pass the value in as an argument. The argument becomes part of the cache key:
async function ProfileContent() { const session = (await cookies()).get('session')?.value return <CachedContent sessionId={session} />}async function CachedContent({ sessionId }: { sessionId?: string }) { 'use cache' const data = await fetchUserData(sessionId) // sessionId is part of the key return <div>{data}</div>}This is the docs' pattern. Be careful with it. A cache entry keyed by a session id is per user and often does not survive between requests on serverless hosts. Use it only when the work is expensive.
Time and random values
Values that change on every run (Date.now(), Math.random(), crypto.randomUUID()) must be handled too. I put Date.now() in a page and built:
Error: Route "/now": Next.js encountered the unstable value `Date.now()` while prerendering.This value can change between renders, so it must be either prerendered or computed later.Ways to fix this: - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call - [cache] Prerender and cache the value with `"use cache"` - [client] Render the value on the client with `"use client"` - [measure] If the value is for telemetry, use a timing API such as `performance.now()`So: if each visitor needs a fresh value, await connection() (from next/server) inside a Suspense boundary; if all visitors can share one, use use cache; if only the browser needs it, compute it in a Client Component. This protects you from freezing a "current time" into a static page by mistake.
Revalidation: refreshing what is cached
Time-based refresh is not enough when the data changes because of an event: someone published a post. You then revalidate on demand. Next.js has several functions. These functions run on the server, in a Server Action or a Route Handler:
| Function | What it does | Where it can be called |
|---|---|---|
revalidateTag(tag, 'max') | Marks all entries with this tag as stale. The next request still gets the old copy, and starts a refresh in the background. | Server Actions and Route Handlers |
updateTag(tag) | Expires the tag at once. The next read waits for fresh data. For "read your own writes". | Server Actions only |
revalidatePath('/blog') | Invalidates the cached data of a path. | Server Actions and Route Handlers |
refresh() | Re-fetches the current route's payload, without touching the data cache. | Server Actions |
I tested two of them with a Route Handler:
// app/api/revalidate/route.tsimport { revalidatePath, revalidateTag } from 'next/cache'export async function POST(req: Request) { const { tag, path } = await req.json() if (tag) revalidateTag(tag, 'max') if (path) revalidatePath(path) return Response.json({ ok: true, tag, path })}Tag, in the Cache Components model, on the /cached page tagged posts:
request 1: hit 1 at 03:15:25.902request 2: hit 1 at 03:15:25.902POST /api/revalidate {"tag":"posts"}after revalidateTag, request A: hit 1 at 03:15:25.902 <- still the old copy(1 second later) request B: hit 3 at 03:15:29.900 <- newrequest C: hit 3 at 03:15:29.900Request A got the stale page, and caused the refresh. Request B got the new one. That is the stale-while-revalidate behaviour of revalidateTag(tag, 'max'). The docs say the second argument decides how long stale content may be served; 'max' (a one-year window) is recommended. The one-argument form revalidateTag('x') is deprecated, and the docs say to move to updateTag in actions or to the 'max' profile.
Tag and path in the default model, with fetch(..., { next: { tags: ['news'] } }):
req 1: hit 3 at 03:16:19.764req 2: hit 3 at 03:16:19.764POST {"tag":"news"}after tag, req A: hit 3 at 03:16:19.764 <- stale, refresh startedreq B (1 s later): hit 5 at 03:16:23.606 <- newPOST {"path":"/tagged"}after path, req C: hit 6 at 03:16:24.608 <- new at oncereq D: hit 6 at 03:16:24.608Two lessons from this. First, tags and revalidateTag work the same way in both models. Second, revalidatePath was immediate: the very next request already got fresh data (and waited for it). That matches the docs: the tag form with 'max' serves stale while it refreshes, and the path form invalidates the route.
The docs give this advice, which I follow: prefer tags to paths. A tag says "this data changed", no matter which pages use it. A path only fixes the page you remembered.
When to use which
- A user submits a form and must see the result right away (a new comment on this page):
updateTagin the Server Action (next lesson shows the real run). - An editor in a CMS publishes, and a delay of a few seconds is fine: a webhook calls a Route Handler that calls
revalidateTag(tag, 'max'). The docs suggest exactly this for CMS content: tag it, use a longcacheLife(likemax), and let the webhook trigger the refresh. - You cannot tell which tag to use, but you know the page:
revalidatePath. - The page depends on something outside the cache (a cookie the action just set):
refresh().
The layers: what is cached where
By now you have met several different caches. It helps to put them in one list:
| Layer | Where | Lasts | What controls it |
|---|---|---|---|
| Request memoization | Server, memory | One render | Automatic for fetch. cache() from React for other functions. |
use cache / data cache | Server (memory by default, or a handler) | cacheLife | use cache, cacheLife, cacheTag, revalidate functions |
| Prerendered output (static shell, static pages) | Build output, or a CDN | Until revalidated or the next deploy | Which APIs you used. The route table shows it. |
| Router cache | The user's browser | stale time (default 5 minutes, header x-nextjs-stale-time: 300) | cacheLife stale, staleTimes, <Link prefetch> |
The last row matters for a surprising bug: you changed data on the server, but a user who clicked around still sees the old page for a few minutes, because the browser kept a copy of the payload. Server Actions that call updateTag, revalidatePath or refresh also send a fresh payload back in the same response (next lesson), which fixes it for the user who made the change.
A cache that "works on my laptop" may not work in production. The docs are clear: the default use cache store is in memory. On a long-running server (self-hosted) entries persist between requests. On serverless hosting, each request may reach a fresh instance, so entries may not be reused at runtime, while build-time caching works normally. For a shared cache the docs point to use cache: remote with a cache handler (like Redis). Also, no cache carries over to a new deploy, because the cache key includes the build id. Decide what you rely on, and test on the host you deploy to.
Which model should I use?
The docs present Cache Components as the model to move toward (they have a migration guide and a long "Caching" page about it), and they keep the older model documented for projects that do not turn it on. This repo uses the older default. My plan:
- New project, new code: turn on
cacheComponentsand learnuse cache. The build tells you every place where you forgot to decide. I found that error-first approach helpful, because it replaces guessing. - Existing project on the default model: keep it working. Learn the table above. Read the route table after every build. Migrate route by route using the migration guide when you are ready.
- Either way: do not mix the two sets of ideas in your head.
export const dynamic = 'force-dynamic'andfetchcache options belong to the default model.use cache,cacheLifeandcacheTagbelong to Cache Components. Docs and blog posts you find online may be about either, so check which one before copying code.
A decision list for a data-reading component
- Is the data the same for every visitor? If yes, it can be cached. If no (cookies, headers, per-user), read the runtime value in a Suspense boundary.
- How fresh must it be? Pick a
cacheLife(orrevalidate). "Seconds old" is fine for a product list. It is not fine for a bank balance. - What event changes it? Tag it with
cacheTag(ornext.tags) and call a revalidate function from the code that changes it. - Are there independent requests? Start them together (
Promise.all) or give each one its own Suspense box. - Are you reading the same thing in many components?
fetchmemoizes. For a database, wrap the read incache(). - Check the route table and the response headers.
Common mistakes
1. Sequential awaits for independent data. My test: 1.5 seconds against 0.5 seconds. Use Promise.all or Suspense.
2. Expecting fetch to refresh on every visit in the default model. A page with no request-time API is built once. The plain page kept the same hit number for every request. Use no-store, revalidate, or a dynamic API when you need fresh data.
3. Putting cookies() or headers() inside use cache. It fails the build. Read it outside and pass a value as an argument.
4. Caching per-user data in a shared place. If the key does not include the user, everybody sees the first user's data. In the Cache Components model the arguments are the key, so pass the user id. In the default model, never cache a fetch that depends on a cookie.
5. revalidateTag('x') with one argument. It is deprecated. Use revalidateTag('x', 'max'), or updateTag('x') in a Server Action.
6. Testing caching in next dev. Dev always renders on demand. Use next build and next start.
7. Fetching your own Route Handler from a Server Component. fetch('http://localhost:3000/api/posts') from a page adds a pointless network hop to your own server. The docs add that for a page prerendered at build time it fails the build, because no server is listening yet. Call the shared function that the handler also uses. (I took this from the docs and did not run it.)
Try it
You need a small backend that counts hits. Use my server.mjs above (a dozen lines) or any API you can watch.
- Make a page with one
fetchto your API and no options. Runnpm run buildandnpm run start. Reload the page 3 times. Does the number change? Look at the route table. - Change it to
{ cache: 'no-store' }. Build again. What does the table say now? - Make a page with three
awaits of a 500 ms endpoint, one after another. Time it withcurl -w "%{time_total}". Then usePromise.all. Compare. - Put the three fetches in three components, each in its own
<Suspense>. What is the time to first byte? - Turn on
cacheComponents: true. Build the page from step 1. What happens, and what are the three ways to fix it? - Add
'use cache',cacheLife('hours')andcacheTag('posts')to a function that fetches. Make a Route Handler that callsrevalidateTag('posts', 'max'). Request the page, call the handler, then request the page twice. What do you see? - Try
cookies()inside the'use cache'function. What does the build say?
Answers
- In the default model the number stays the same, because the page is static (
○) and was fetched once at build. - The route becomes
ƒ(dynamic) and the number changes on each request. - About 1.5 s sequentially against about 0.5 s with
Promise.all. - Under 20 ms for the first byte (the shell with the three fallbacks), and the page completes at the slowest box's delay.
- The build fails with "encountered uncached or runtime data during prerendering". Fixes: wrap the access in
<Suspense>, cache it withuse cache, or setexport const instant = false. - The first request after the handler call still shows the old data, and starts a refresh. The following request shows the new data.
- It fails: "used
cookies()inside 'use cache'", and tells you to read it outside and pass it as an argument.
Interview questions
How do you fetch data in the App Router? (Must know)
In a Server Component. I make the component async and await a fetch or a database call, and return JSX. There is no useEffect and no loading state to manage, and the browser gets HTML that already has the data. Credentials stay on the server. For a Client Component that really must fetch (live updates, after a click), I use SWR or TanStack Query, or pass a promise from the server and read it with use.
What is request memoization, and when do I need cache()? (Must know)
Within one render, identical fetch calls (same URL and options) are sent once, so a layout and a page can both fetch the same user without double requests. It lasts only for that render. It applies to fetch only. For an ORM or a raw database call I wrap the function with React's cache(). In my test, three identical fetches produced one API hit, and a function wrapped in cache() ran once instead of twice.
Why is fetching sequentially a problem, and how do you avoid it? (Must know)
Each await pauses the component, so independent requests that follow one another add up. In my test, three 500 ms calls took about 1.5 s in a row and 0.5 s with Promise.all. I start independent requests together. Or I give each part its own <Suspense> boundary, so the first byte arrives in about 10 ms and each box fills in when its data is ready. Sequential is right only when the second call needs a value from the first.
In the default (older) model, how does Next.js decide a page is static or dynamic? (Senior signal)
A page is prerendered at build time unless it uses a request-time API (cookies(), headers(), searchParams, an unlisted dynamic param) or an uncached fetch such as cache: 'no-store'. So a plain fetch with no options in a page with no request data becomes static, and its data is frozen at build. I checked this: such a page called my API once during the build and showed the same hit number forever. I always read the route table. ○ means static, ƒ means dynamic, and a Revalidate column shows time-based refresh.
What changes with cacheComponents: true? (Must know)
Everything is dynamic by default and I opt into caching. I put 'use cache' on a function, component or file, and set a lifetime with cacheLife and a label with cacheTag. Next.js builds a static shell with the static and cached parts, and streams the rest in at request time (Partial Prerendering, shown as ◐). If I read uncached or runtime data outside a <Suspense>, the build fails and offers three fixes: stream it with Suspense, cache it, or allow a blocking route. The old dynamic, revalidate and fetchCache route options are for the other model.
What do stale, revalidate and expire mean in cacheLife? (Senior signal)
stale is how long the browser can reuse its own copy without asking. revalidate is how long the server's entry stays fresh. After that the next request gets the old copy while a refresh runs in the background. expire is the longest the old copy may be served. After it, the next request waits for a new result. For example, the hours profile is 5 minutes, 1 hour and 1 day. I saw it in the headers: s-maxage=3600, stale-while-revalidate=82800. Very short values (expire under 5 minutes, or stale under 30 seconds) cannot be part of the static shell, so they need Suspense.
revalidateTag, updateTag and revalidatePath: what is the difference? (Senior signal)
revalidateTag(tag, 'max') marks the tag stale and uses stale-while-revalidate: the next request still gets the old data and triggers a refresh. I measured it: request A old, request B new. updateTag(tag) expires the tag at once, so the next read waits for fresh data. It works only in Server Actions and is meant for "read your own writes". revalidatePath(path) invalidates a route. In my test the very next request had new data. I prefer tags because they follow the data, not the page, and I use updateTag when the user who made the change must see it immediately.
Why can't I call cookies() inside a function marked use cache? (Trap)
A cache entry is shared between requests, but cookies are different for each request. If it were allowed, one user's value could be cached and shown to others. The build fails with a clear message. The fix is to read the cookie outside the cached function, in a component under <Suspense>, and pass the value as an argument. Arguments become part of the cache key, so each value gets its own entry.
What happens if I use Date.now() or Math.random() in a page with Cache Components? (Trap)
The build fails: the value would be frozen into the static page. The error lists the fixes. If every visitor needs a fresh value, call await connection() first and wrap the component in <Suspense>. If one shared value is fine, put it inside use cache. If only the browser needs it, compute it in a Client Component. performance.now() is allowed because it is for timing.
Why might caching behave differently in production than on my machine? (Senior signal)
First, next dev always renders on demand and never shows static behaviour. Second, the default use cache store is in memory. On a long-lived server it persists across requests. On serverless hosting, instances are short-lived, so runtime entries may not be reused, although build-time caching works. For a shared durable cache I would use use cache: remote with a cache handler. Third, the cache does not carry over between deploys. I test with next build and next start, and on the real host.
How do you refresh content when an editor publishes in a CMS? (Senior signal)
I tag the cached CMS data with cacheTag('posts') and give it a long cacheLife, such as max. I set the CMS to call a webhook, which is a Route Handler that checks a secret and calls revalidateTag('posts', 'max'). Visitors keep getting a fast cached page, and the first request after the publish triggers a refresh in the background. This avoids refreshing on a timer when nothing has changed.
A user changed their name, but the page still shows the old one for a few minutes. Why? (Trap)
There can be two reasons. One is the server cache: the action did not invalidate the tag or path that the page uses. The other is the browser's router cache, which keeps a copy of the page payload for a stale time (5 minutes by default, I saw x-nextjs-stale-time: 300). A Server Action that calls updateTag, revalidatePath or refresh returns a fresh payload for the current route in the same response, which handles both. I would check which of the two is stale: server data (curl the page) or the browser's copy (hard reload).
Key takeaways
- In a Server Component you fetch with
async/await, no effects needed. Credentials never reach the browser. - Identical
fetchcalls in one render are memoized. Wrap non-fetchreads with React'scache(). It lasts only for one render. - Start independent requests together or stream them with Suspense. I measured 1.5 s against 0.5 s, and a first byte at 10 ms.
- Default model: a page without request-time APIs is prerendered at build, so even a plain
fetchis frozen. Useno-store,revalidateor dynamic APIs for fresh data, and read the route table. - Cache Components (
cacheComponents: true): dynamic by default, you adduse cache,cacheLifeandcacheTag. Uncached data outside Suspense fails the build. - Arguments are the cache key. Runtime data (
cookies,headers) cannot be read insideuse cache; pass it in.Date.now()andMath.random()needconnection()or caching. revalidateTag(tag, 'max')is stale-while-revalidate.updateTag(Server Actions only) is immediate.revalidatePathinvalidates a route. Prefer tags.- The default in-memory cache behaves differently on serverless hosts, and nothing carries over between deploys. Test with
buildandstart.
What's next
Next: Server Actions, Forms and Mutations. Reading data is half of the job. When a user submits a form, a Server Action changes the data on the server, then asks Next.js to refresh the right caches. You will run an action with and without JavaScript, validate input, show a pending state, test updateTag for real, and see why every action must check who is calling. Practise now: build the /cached page from this lesson with a tag, and a Route Handler that revalidates it, then watch the request counter of your own API.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Cost, Latency and Caching for LLM Features · AI Engineering
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- “The UI Is Fast but the API Takes Too Long”: How to Solve It · Scenario Debugging
Part of the study guide
Related series