Next.js App Router
Rendering Modes: Static, Dynamic, Streaming and Suspense
Static and dynamic rendering, how Next.js decides, loading UI and streaming, partial prerendering ideas, and generateStaticParams.
On this page
- Words used in this lesson
- Static or dynamic: what decides
- Streaming: sending a page in pieces
- The hard rule: the status code is sent first
- The new model: Partial Prerendering with Cache Components
- generateStaticParams, in one paragraph
- A decision list
- Common mistakes
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: Every page in Next.js is either built ahead of time (static) or built when someone asks (dynamic), and Next.js picks for you from what your code touches. On top of that, a dynamic page can be sent in pieces: the fast parts first, the slow parts later, each inside its own <Suspense> boundary. That is streaming. It makes pages feel fast, but it has one hard rule: once the first piece is sent, the HTTP status code is already decided.
In earlier lessons you saw ○ and ƒ in the route table, and you met loading.tsx. Here we open the box. How does Next.js choose? What does the browser really receive, and when? Why can a page that does not exist return 200? I measured each of these with a running app. I timed the chunks of the HTML as they arrived, so you can see streaming as numbers, not as a picture in the docs.
This is lesson 6 of ten. Lesson 2 already taught fetching and the two caching models. This lesson uses them, so skim lesson 2 first if "default model" and "Cache Components" are new words. The next lesson, Metadata, SEO, Images and Fonts, will show one place where streaming meets search engines: the <title>. For the browser side (what "paint" means), see Core Web Vitals for Engineers and Concurrent React, Suspense and Server Components in Next.js.
What I ran / checked. Linux, Node.js v22.22.0, Next.js 16.3.5 (this repo's version), React 19.3.0, Turbopack. I built two throwaway apps in /tmp: one with the default settings (the "default model"), one with cacheComponents: true (the "Cache Components model"). I ran next build and next start, read response headers with curl, recorded the arrival time of every chunk of the HTML with a small Node script, and sampled what a real Chromium page showed with Playwright every 40 ms. Sources I read (in my own words): inside node_modules/next/dist/docs/01-app/, the guides "Streaming", "Rendering Philosophy", "Caching", "Caching and Revalidating (Previous Model)", "Migrating to Cache Components", the loading, cacheComponents and connection pages. Not run: I did not test PPR behind a real CDN or on Vercel, and I did not test redirect() or an error thrown in the middle of a stream. For those two I quote what the docs say and mark it. Timings are from one small machine and will vary. Look at the shape, not the exact milliseconds.
Words used in this lesson
- Static rendering (prerendering) means the page is built once, at build time, and served as a ready file.
- Dynamic rendering means the page is built on the server for every request.
- Request-time APIs are things that only exist when a request arrives:
cookies(),headers(),searchParams, andparamsthat were not listed ahead of time. - ISR (Incremental Static Regeneration) means a static page that is rebuilt in the background after some seconds, so it stays fairly fresh.
- Streaming means the server sends the HTML in several pieces (chunks), the first ones right away and the others when they are ready.
- Chunk is one piece of a streamed response.
- Suspense is a React component. You wrap slow content in
<Suspense fallback={...}>. React shows the fallback first and swaps in the real content later. - Fallback is the placeholder shown while the real content is not ready, such as "Loading...".
- Static shell is the part of a page that does not wait for anything: layouts, static text and the fallbacks.
- Partial Prerendering (PPR) means one page has a static shell built at build time, with dynamic holes that stream in at request time. The build table shows
◐. - Time to first byte (TTFB) is how long the browser waits until the first piece of the response arrives.
- Status code is the number at the top of an HTTP response (
200,404). It is sent before the body.
Static or dynamic: what decides
Think of two kinds of restaurant. One cooks a big batch in the morning and serves from the tray. The other cooks each plate when you order. A static page is the batch. A dynamic page is cooked to order.
In the default model (no cacheComponents in the config, which is how this repo is set up), a route is static unless your code touches something that only a real request can give. Those things switch it to dynamic:
cookies()orheaders().- The
searchParamsprop on a page. connection()fromnext/server(an explicit "wait for a real request").export const dynamic = 'force-dynamic'.- A
fetchwithcache: 'no-store', or afetchthat comes after one of the things above. - Using
paramsfor a value not listed ingenerateStaticParams(it is built on the first request, then kept).
I made one page for each and read the table after next build:
// app/static-time/page.tsx no request dataexport default function Page() { return <p id="t">rendered at {new Date().toISOString()}</p> }// app/dyn-cookies/page.tsx reads cookies()export default async function Page() { const theme = (await cookies()).get('theme')?.value ?? 'light' return <p>theme={theme} at {new Date().toISOString()}</p>}// app/dyn-search/page.tsx reads searchParamsexport default async function Page(props: PageProps<'/dyn-search'>) { const { q } = await props.searchParams return <p>q={String(q)}</p>}// app/force/page.tsxexport const dynamic = 'force-dynamic'// app/isr/page.tsxexport const revalidate = 5// app/posts/[id]/page.tsx with generateStaticParams returning ids 1 and 2The real route table:
Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found├ ƒ /dyn-cookies├ ƒ /dyn-search├ ƒ /force├ ○ /isr 5s 1y├ ƒ /nf-before├ ƒ /nf-inside├ /posts/[id]│ ├ ● /posts/1│ └ ● /posts/2├ ƒ /slow-loading├ ○ /static-time└ ƒ /stream/static-time shows ○ even though its code calls new Date(). Next.js does not know that new Date() should change. So it runs the code once, during the build, and keeps the answer. /isr shows ○ too, but with Revalidate 5s. /dyn-cookies, /dyn-search and /force show ƒ.
The same fact seen from the outside
The route table is what Next.js says. I also want what the server sends. I asked each page for its headers:
/ HTTP/1.1 200 OK x-nextjs-cache: HIT Cache-Control: s-maxage=31536000/static-time HTTP/1.1 200 OK x-nextjs-cache: HIT Cache-Control: s-maxage=31536000/dyn-cookies HTTP/1.1 200 OK Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate/force HTTP/1.1 200 OK Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate/isr HTTP/1.1 200 OK x-nextjs-cache: STALE Cache-Control: s-maxage=5, stale-while-revalidate=31535995/posts/1 HTTP/1.1 200 OK x-nextjs-cache: HIT Cache-Control: s-maxage=31536000/posts/9 HTTP/1.1 200 OK x-nextjs-cache: MISS Cache-Control: s-maxage=31536000/stream HTTP/1.1 200 OK Cache-Control: private, no-cache, no-store, max-age=0, must-revalidateRead the Cache-Control header. A static page says s-maxage=31536000 (one year, for shared caches such as a CDN). A dynamic page says private, no-cache, no-store, so no cache may keep it (the docs say Next.js does this so user-specific data is never cached). So the static or dynamic choice also decides what a CDN may do. (For Cache-Control itself, see HTTP Caching.)
/posts/9 is interesting. It is not in generateStaticParams, and the answer was MISS: Next.js built it on this first request. After that it is a HIT, like the others.
Static means frozen
To prove "built once", I asked /static-time twice, two seconds apart, and /force twice, one second apart:
static-time: rendered at 2026-10-12T03:11:54.881Z rendered at 2026-10-12T03:11:54.881Z <- same, built at build timeforce: forced at 2026-10-12T03:12:28.766Z forced at 2026-10-12T03:12:29.788Z <- new every requestThis is the most common surprise for beginners: "my page shows an old time/price". The page was never dynamic. And in next dev, you will never see it, because the dev server renders on demand. Always test with build and start.
ISR: static, but refreshed
/isr has export const revalidate = 5. I asked for it four times, three seconds apart. The page prints the time it was rendered. The header says if the copy is fresh:
isr rendered at 2026-10-12T03:12:20.844Z x-nextjs-cache: STALEisr rendered at 2026-10-12T03:12:29.806Z x-nextjs-cache: HITisr rendered at 2026-10-12T03:12:29.806Z x-nextjs-cache: STALEisr rendered at 2026-10-12T03:12:35.908Z x-nextjs-cache: HITHow to read it:
- The first request came more than 5 seconds after the build. The old copy (from
20.844) was returned, markedSTALE. That request also started a rebuild in the background. - The second request got the new copy (
29.806), markedHIT. - Three seconds later, that copy was older than 5 seconds again, so the third request got it as
STALEand started another rebuild. - The fourth request got the newest copy.
This is stale-while-revalidate: nobody waits for the rebuild, and the price is that one visitor can see an old page. It matches the header s-maxage=5, stale-while-revalidate=... that you saw above. Lesson 2 has the full story of revalidation, including revalidateTag for refreshing on demand.
Streaming: sending a page in pieces
Now the dynamic side. Suppose a page needs data that takes 1.5 seconds. The old way (classic server rendering) waits for everything, then sends the whole HTML. The user sees a blank screen for 1.5 seconds. With streaming, the server sends what it has, at once, and sends the rest later.
React builds the HTML in chunks that line up with <Suspense> boundaries. Here is my test page. It has two slow components in two separate boundaries:
// app/stream/page.tsximport { Suspense } from 'react'import { connection } from 'next/server'const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))async function Fast() { await connection(); await sleep(300); return <p id="fast">FAST BOX ready</p> }async function Slow() { await connection(); await sleep(1500); return <p id="slow">SLOW BOX ready</p> }export default function Page() { return ( <main> <h1>Streaming demo</h1> <Suspense fallback={<p>loading fast...</p>}><Fast /></Suspense> <Suspense fallback={<p>loading slow...</p>}><Slow /></Suspense> </main> )}connection() just tells Next.js "do this at request time", so the page is dynamic. The sleep pretends to be a slow database.
To see the stream, I wrote a short Node script that makes the request and prints a line each time a chunk of the response arrives:
status 200 transfer-encoding: chunked content-length: undefined 25 ms chunk of 987 bytes contains: loading fast..., loading slow... 25 ms chunk of 4227 bytes contains: loading fast..., loading slow... 321 ms chunk of 115 bytes contains: FAST BOX ready 322 ms chunk of 920 bytes contains: FAST BOX ready 1521 ms chunk of 115 bytes contains: SLOW BOX ready 1522 ms chunk of 91 bytes contains: SLOW BOX ready 1522 ms chunk of 14 bytes 1523 ms endWhat this tells us:
transfer-encoding: chunkedand nocontent-length. The server does not know the total size yet. It sends pieces.- At 25 ms the shell arrived: the
<h1>and both fallbacks ("loading fast...", "loading slow..."). - At ~320 ms the first box arrived. At ~1520 ms the second one.
- The total is about 1.5 seconds, not 1.8. The two components started at the same time. Each boundary resolves by itself and does not wait for its sibling.
The chunks after the shell are small: roughly 115 bytes for the content, plus a short script that tells the browser to swap the fallback for the new HTML. The docs describe it the same way: React streams the finished HTML plus inline scripts that replace the fallback.
Then I looked at the same page in a real browser. Playwright asked Chromium for the visible text every 40 ms and printed each change:
45 ms: Streaming demo | loading fast... | loading slow... 383 ms: Streaming demo | FAST BOX ready | loading slow... 1523 ms: Streaming demo | FAST BOX ready | SLOW BOX readyThat is the user's view. The page is useful at 45 ms. Without Suspense, they would have waited 1,500 ms for anything.
Streaming does not hurt SEO. The docs say that streamed content is still server-rendered HTML, so crawlers that read HTML get it. The one place where it matters is metadata, and lesson 7 shows what I measured for <title>.
loading.tsx is a Suspense boundary for the whole page
You learned in lesson 0 that loading.tsx shows a loading screen. Now you know how: Next.js wraps your page in a <Suspense> and uses the loading file as the fallback. The layout stays outside the boundary, so it is part of the shell. I made a page that waits 1.2 seconds, with a loading.tsx next to it:
status 200 transfer-encoding: chunked content-length: undefined 34 ms chunk of 952 bytes contains: skeleton 34 ms chunk of 4145 bytes contains: skeleton 1230 ms chunk of 100 bytes contains: Slow page done 1231 ms chunk of 1138 bytes contains: Slow page done 1231 ms chunk of 14 bytes 1232 ms endSame shape: the skeleton at 34 ms, the real page at 1230 ms. The difference from the first test is the size of the unit. loading.tsx streams the whole page as one piece. A <Suspense> inside the page streams one part. The docs give this table, and I agree with it:
loading.tsx | <Suspense> | |
|---|---|---|
| Scope | The whole page | Any component |
| Setup | Add one file | Wrap components yourself |
| Navigation | Prefetched as an instant fallback | Not prefetched by default |
| Best for | Pages where nothing can show without the data | Most pages, for fine control |
The docs advice is to prefer explicit <Suspense> boundaries close to the slow part. A loading.tsx high in the tree works, but then the whole page becomes a skeleton, even the parts that were fast.
Push the slow part down
A rule from the docs deserves a plain explanation: do not await request data at the top of a page or layout if only a small part needs it. If a layout does await cookies() at the top, everything below must wait for it, and nothing below can be part of the shell. Instead, pass the promise down, and let the small component that needs it read it inside its own <Suspense>:
// from the docs, shortened: the layout does NOT awaitexport default function DashboardLayout({ children }: { children: React.ReactNode }) { const cookieStore = cookies() // start the work, don't await return ( <div> <Nav> <Suspense fallback={<p>Loading user...</p>}> <UserMenu cookiePromise={cookieStore} /> </Suspense> </Nav> {children} </div> )}I did not run this example. It is from the docs, and I trust it because it follows from what I measured: only the boundary that suspends is delayed. It is the same idea as my Fast and Slow boxes.
The hard rule: the status code is sent first
An HTTP response starts with a status line (HTTP/1.1 200 OK), then headers, then the body. When you stream, the status line and headers go out with the first chunk. After that, you cannot change them. The docs say this plainly: "You cannot change the status code or headers after streaming starts."
What happens, then, when a component deep inside a <Suspense> calls notFound()? The server already said 200. I tested two pages. In both, the page does not exist. In one, I call notFound() before any Suspense. In the other, I call it inside a component under a <Suspense>:
// app/nf-before/page.tsxexport default async function Page() { await connection() notFound() // before any Suspense return <Suspense fallback={<p>x</p>}><Body /></Suspense>}// app/nf-inside/page.tsxasync function Body(): Promise<React.ReactNode> { await connection(); await new Promise(r => setTimeout(r, 300)); notFound()}export default function Page() { return <main><h1>Post</h1><Suspense fallback={<p>loading post...</p>}><Body /></Suspense></main>}nf-before: HTTP/1.1 404 Not Found<meta name="robots" content="noindex"/>nf-inside: HTTP/1.1 200 OK<meta name="robots" content="noindex"/>The first page returned a real 404. The second returned 200 OK, and its HTML still contained the "loading post..." fallback and then the "This page could not be found" message. A user sees a 404 screen. But the status code says success. Next.js tries to limit the harm: it adds <meta name="robots" content="noindex"> to the HTML, so search engines should not index the page. You can see that tag in the second result.
The rule from this: if you need a real status code (404, 301), decide it before the stream starts. That means: check that the resource exists first (a fast check), call notFound() before the Suspense boundary, and only then stream the slow parts. The docs show exactly this pattern, with a quick checkSlugExists before the boundary. You can also reject early in proxy.ts or with redirects in next.config.ts, because those run before the page.
The docs also say, and I did not test, that a redirect() called in the middle of a stream becomes a client-side redirect (the browser is told to navigate), not an HTTP 3xx. And an error thrown after streaming started is caught by the nearest error.tsx, which replaces only that section.
A 200 "not found" page is a "soft 404". Users do not notice. Monitoring does. Your uptime tool sees 200. Search engines may treat it as a thin page. If your page has generateStaticParams, or reads data in a layout, think about where you decide "this does not exist".
The new model: Partial Prerendering with Cache Components
So far, a route was either ○ or ƒ. That was the default model. The newer model (cacheComponents: true) drops that all-or-nothing choice. The docs say the boundary between static and dynamic is "at the component level, not the route level". One page can have a static shell, cached parts and streaming parts together. The build table marks such a page ◐ (Partial Prerender).
I built a small app with the flag on. One page, three kinds of content:
// app/shell/page.tsxasync function Products() { 'use cache' cacheLife('minutes') await sleep(800) // pretend: slow database query return <p id="products">products fetched at {new Date().toISOString()}</p>}async function Greeting() { const theme = (await cookies()).get('theme')?.value ?? 'light' await sleep(500) return <p id="greeting">your theme: {theme}</p>}export default function Page() { return ( <main> <h1>Static shell title</h1> <Products /> {/* cached */} <Suspense fallback={<p>loading your theme...</p>}> <Greeting /> {/* request time */} </Suspense> </main> )}The route table:
Route (app) Revalidate Expire┌ ○ /_not-found└ ◐ /shell 1m 1h○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentThe ◐ says: the shell is prebuilt, the rest streams. Then the chunk timeline:
status 200 transfer-encoding: chunked content-length: undefined 86 ms chunk of 1034 bytes contains: products fetched, your theme, loading your theme, Static shell title 128 ms chunk of 4318 bytes contains: products fetched, your theme, loading your theme, Static shell title 612 ms chunk of 129 bytes contains: your theme 615 ms chunk of 935 bytes contains: your theme 619 ms endAt 86 ms the browser already had the title, the products (which cost 800 ms to compute, but were computed at build time and are part of the shell) and the fallback for the theme. At 612 ms the theme arrived. If the products had not been cached, the 800 ms would have been in the request, and this page would have been late.
I asked twice, one second apart:
products fetched at 2026-10-12T03:13:17.430Zproducts fetched at 2026-10-12T03:13:17.430ZThe same timestamp. The cached part is not recomputed for each request. Then I asked with a cookie (Cookie: theme=dark). The streamed part contained your theme: dark, and the fallback still appeared in the first chunk. So per-user content is never baked into the shared shell.
Rules the new model enforces
The build enforces these. They remove the quiet surprises of the default model:
- You must handle runtime data. I made a page that reads
cookies()with no<Suspense>around it.next buildfailed (the full error is in lesson 5):
Error: Route "/bad": Next.js encountered uncached or runtime data during prerendering....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 routeSo Next.js gives you three choices for slow or per-user data: stream it (Suspense), cache it (use cache), or block (instant = false, which says "I accept that this route waits").
- The old route config is not allowed. I added
export const dynamic = 'force-dynamic'to a page. The build failed:
Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.The docs say dynamic, revalidate and fetchCache are replaced by use cache and cacheLife. If you turn the flag on in an old project, plan to remove those exports.
- Time and random values need a decision.
Date.now(),Math.random()andcrypto.randomUUID()give a different answer every time, so the new model asks you either to callawait connection()first (new value per request) or to cache the result (same value for everyone).
A note on what is a "dynamic" fetch in this model: the docs say data fetching is dynamic by default. You choose what to cache, instead of choosing what to make dynamic. The default flips.
Do not turn cacheComponents on just because it is new. It is a different model with its own rules, and it asks you to touch every route that reads request data. This repo does not enable it. It is a good fit when you have mixed pages (a static layout plus personal parts). It is less useful for a site that is fully static, like a blog. I would turn it on in a new app, or migrate one route at a time following the docs' migration guide.
One thing I observed and could not explain
On /shell (with the flag on), the response headers were:
HTTP/1.1 200 OKx-nextjs-prerender: 1x-nextjs-postponed: 1Cache-Control: private, no-cache, no-store, max-age=0, must-revalidatex-nextjs-prerender and x-nextjs-postponed tell me that the shell came from a prerender and that the rest was "postponed" to request time. But the Cache-Control was the private one. That means a CDN would not cache this response by default. I only observed this. I did not research it further, and I did not test it behind a real CDN. The docs have a CDN caching guide and a PPR platform guide for that topic. If you care about CDN behavior, read those and test on your own platform.
generateStaticParams, in one paragraph
You met it in lesson 0. It lists the values of a dynamic segment to build at build time. For the others, the default is "build on first request" (I saw MISS for /posts/9). In the Cache Components model, the docs say that params you do not list are request-time data, and they suggest streaming them under Suspense or using ISR with Cache Components. Either way, listing your top pages in generateStaticParams is the cheap win: those pages are built before any user comes.
A decision list
When you build a page, ask in this order:
- Does the page need anything from the request (cookie, header, search params, a user)? If not, it can be static, and you are done.
- If it does, which small part needs it? Put only that part in a
<Suspense>with a fallback of the right size. - Is the slow data the same for everyone? Cache it (
use cachein the new model,fetchcache options orrevalidatein the default model). - Do you need a real status code (404, redirect)? Decide before the first
<Suspense>. - After
next build, read the route table. Is each route what you expected? - Check with
curl -i. IsCache-Controlwhat you want a CDN to do?
Common mistakes
- Testing caching in
next dev. Dev renders every page on demand. Usenext buildandnext start. - Reading
cookies()in a root layout. In the default model, every page below becomes dynamic. - One big
<Suspense>around the whole page. You lose the point: nothing is in the shell. - Fallbacks with a different height than the content. The page jumps when the content arrives (a layout shift; lesson 7 measures one).
- Calling
notFound()inside a Suspense boundary and expecting a404status. - Expecting
export const revalidate = 5to mean "refresh every 5 seconds on a timer". It means "stale after 5 seconds; the next visit starts a rebuild". - Using
export const dynamicwithcacheComponentson. It is a build error. - Not reading the route table. It tells you the truth.
Try it
Make a fresh app. Use next build and next start for everything.
- Make
app/time/page.tsxthat printsnew Date().toISOString(). Build and start. Ask for it twice withcurl. Same time or different? What does the route table show? - Add
await connection()at the top of the page (import { connection } from 'next/server'). Build again. What changed in the table and in the output? - Make two async components that sleep 300 ms and 1500 ms, each in its own
<Suspense>. Runcurl -N localhost:3000/streamand watch when each piece appears. How long does the whole response take: about 1.8 or 1.5 seconds? - Wrap both in one
<Suspense>instead. When does the first piece appear now? - Make a page that calls
notFound()before a<Suspense>, and another that calls it inside. Usecurl -Ion both. What are the status codes? - Add
export const revalidate = 5to a static page that prints the time. Ask three times, four seconds apart, and print the headerx-nextjs-cache. - Optional: set
cacheComponents: true. What does the build tell you about the pages that readcookies()?
Answers
- The same time both times. The route is
○(static), frozen at build time. - It becomes
ƒand the time changes on every request.connection()makes it request-time. - About 1.5 seconds. Two sibling boundaries start together and resolve on their own.
- Not until the slower one is ready (about 1.5 seconds), because one boundary waits for both.
404for the one before Suspense.200for the one inside it (with anoindexmeta tag).- The first answer is
STALEorHIT, depending on how old the build is. After 5 seconds, one request returnsSTALEand starts a rebuild, and the next returns the new copy. - A build error that says runtime data was accessed outside
<Suspense>. Wrap the part that reads the cookie, or cache it.
Interview questions
What is the difference between static and dynamic rendering in Next.js? (Must know)
Static rendering builds the page once at build time and serves the same file to everyone. Dynamic rendering builds it on the server for each request. In the default model, a route is static unless it uses request data: cookies(), headers(), searchParams, connection(), dynamic = 'force-dynamic', or an uncached fetch after one of those. I check the route table after next build: ○ static, ● static with generateStaticParams, ƒ dynamic. The choice also changes Cache-Control: I saw s-maxage=31536000 for static and private, no-cache, no-store for dynamic.
My page shows an old time or price in production but is correct in dev. Why? (Trap)
In dev, every page renders on demand, so nothing is cached. In production, a page that uses no request data is built once at build time, so new Date() or a plain fetch ran once and was frozen. I proved it: the same timestamp came back two seconds apart. The fix is to make it dynamic on purpose (connection(), no-store, or reading request data), to give it a revalidate time (ISR), or to move the changing part into a Suspense boundary. And always test with next build and next start.
What is streaming, and how does it work in the App Router? (Must know)
The server sends the HTML in chunks, using chunked transfer encoding, instead of waiting for all of it. The static shell goes first (layouts, static content and the <Suspense> fallbacks). Each Suspense boundary resolves on its own, and React sends its HTML plus a small script that swaps out the fallback. I measured it: the shell at 25 ms, a fast box at 320 ms, and a slow box at 1,520 ms, with no content-length header. It makes the page useful sooner. It does not change the total work.
What does loading.tsx do under the hood, and when would you use <Suspense> instead? (Senior signal)
loading.tsx wraps the page in a <Suspense> boundary and uses the file as the fallback. The layout stays outside, so it is in the shell. It is a whole-page boundary, so a page with one fast part and one slow part becomes a skeleton as a whole. I use explicit <Suspense> boundaries close to the slow component, so the fast parts show at once. I keep loading.tsx for pages where nothing can show without the data. The docs also say a loading.tsx is prefetched as an instant fallback during navigation, and a plain Suspense is not by default.
A page that does not exist returns 200. How? (Trap)
If notFound() runs inside a <Suspense> boundary, the stream has already started, so the status line (200) was already sent and cannot change. I tested two pages: notFound() before the boundary gave 404, and inside the boundary gave 200 with a noindex meta tag that Next.js adds to protect SEO. If I need a real 404, I check that the resource exists before the first Suspense boundary. I can also reject early in proxy.ts or with config redirects, since those run before rendering.
What does export const revalidate = 5 really do? (Senior signal)
It makes the route static with ISR. After the build, the page is served from the cache. When a request comes in after 5 seconds, it still gets the old copy (x-nextjs-cache: STALE) and starts a rebuild in the background. The next request gets the new copy. So it is stale-while-revalidate, not a timer. I saw the sequence STALE, HIT, STALE, HIT across four requests with the render time changing between them. The Cache-Control was s-maxage=5, stale-while-revalidate=....
What does Partial Prerendering give you that the old static or dynamic choice did not? (Senior signal)
A single route can have a prebuilt static shell, cached parts, and parts that stream at request time. In the old model, one cookies() call turned the whole route dynamic. With cacheComponents: true, I got a ◐ route: the first chunk had the title, an 800 ms cached query result (computed at build time), and a fallback for the personal part, all by 86 ms, and the cookie-based part arrived at about 612 ms. The cached part kept the same timestamp across requests.
What happens in the Cache Components model if you read cookies() outside <Suspense>? (Must know)
The build fails with "Next.js encountered uncached or runtime data during prerendering". It offers three fixes: stream it with Suspense, cache it with use cache (this does not work for cookies() itself, which is request data), or allow blocking with instant = false. This is the point of the model: it turns a silent slowdown into a visible decision. It also rejects the old dynamic, revalidate and fetchCache exports with a build error, which I confirmed for dynamic.
Where do you put await cookies() in a layout, and why is the top bad? (Senior signal)
I do not await it at the top. A top-level await on request data in a layout holds back everything below it, so no child can be part of the shell, and the first chunk is delayed. The docs suggest starting the call without awaiting, passing the promise to the small component that needs it, and letting that component read it inside its own Suspense. Then only that part waits. This follows from what I measured: only the boundary that suspends is delayed.
How can you check what Next.js decided for a route, without reading the code? (Must know)
Three ways. The route table after next build (○, ●, ƒ, ◐, plus the Revalidate column). The response headers with curl -i: Cache-Control, x-nextjs-cache (HIT, MISS, STALE) and x-nextjs-prerender. And the chunk timing with a script or curl -N: if there is no content-length and pieces arrive at different times, it is streaming. I use all three when I debug "why is this page stale" or "why is this page slow".
Is streaming bad for SEO? (Trap)
No. The streamed content is server-rendered HTML, so crawlers that read HTML get it. The docs say so. The things to watch are the status code (a streamed 404 is 200 plus noindex) and the metadata. For bots that cannot run JavaScript, Next.js waits for generateMetadata before sending the page, so the <title> lands in the <head>. Lesson 7 shows what I measured with a browser user agent and with Twitterbot.
Key takeaways
- Static means built once at build time. Dynamic means built for each request. Request data (
cookies(),headers(),searchParams,connection()) makes a route dynamic in the default model. - Read the route table, and check
Cache-Controlandx-nextjs-cache. Static pages says-maxage=31536000. Dynamic pages sayprivate, no-cache, no-store. - Static pages are frozen: my
new Date()page gave the same time on every request. Test withbuildandstart, neverdev. revalidate = Nis stale-while-revalidate: one visitor gets the old copy, and the next gets the new one.- Streaming sends the shell first and each
<Suspense>boundary when it is ready. I measured 25 ms, 320 ms and 1,520 ms. Sibling boundaries run in parallel. loading.tsxis a Suspense boundary for the whole page. Prefer small boundaries near the slow part. Do notawaitrequest data at the top of a layout.- After streaming starts, the status code is fixed.
notFound()inside Suspense gave200plusnoindex. Decide404and redirects before the first boundary. - With
cacheComponents: true, a page can be partly static (◐). The build forces you to stream, cache or block every piece of runtime data, and it rejects the olddynamicandrevalidateexports. - Do not turn on a new rendering model without reading its migration guide.
What's next
Next: Metadata, SEO, Images and Fonts. Everything the search engine and the social network see comes from the <head>: the title, the description, the canonical link, the share image. You will see how the Metadata API builds those tags, which metadata arrives in the first bytes and which streams, how sitemap.ts and robots.ts work, how next/image and next/font reduce layout shift, and what I measured. Practise now: add a slow <Suspense> box to a page, then watch the chunks arrive with curl -N.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Concurrent React, Suspense and Server Components in Next.js · Senior Frontend Depth
- What Happens When You Type a URL (The Full Version) · The Browser Contract
- Browser Rendering and the Event Loop in Practice · The Browser Contract
Part of the study guide
Related series