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.

Lesson 0615 min read#nextjs#rendering#streaming#suspense

On this page

  1. Words used in this lesson
  2. Static or dynamic: what decides
  3. Streaming: sending a page in pieces
  4. The hard rule: the status code is sent first
  5. The new model: Partial Prerendering with Cache Components
  6. generateStaticParams, in one paragraph
  7. A decision list
  8. Common mistakes
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. 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, and params that 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() or headers().
  • The searchParams prop on a page.
  • connection() from next/server (an explicit "wait for a real request").
  • export const dynamic = 'force-dynamic'.
  • A fetch with cache: 'no-store', or a fetch that comes after one of the things above.
  • Using params for a value not listed in generateStaticParams (it is built on the first request, then kept).

I made one page for each and read the table after next build:

JavaScript
// 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 2

The real route table:

TEXT
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:

TEXT
/              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-revalidate

Read 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:

TEXT
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 request

This 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:

TEXT
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: HIT

How to read it:

  1. The first request came more than 5 seconds after the build. The old copy (from 20.844) was returned, marked STALE. That request also started a rebuild in the background.
  2. The second request got the new copy (29.806), marked HIT.
  3. Three seconds later, that copy was older than 5 seconds again, so the third request got it as STALE and started another rebuild.
  4. 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:

JavaScript
// 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:

TEXT
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  end

What this tells us:

  • transfer-encoding: chunked and no content-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:

TEXT
   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 ready

That 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:

TEXT
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  end

Same 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>
ScopeThe whole pageAny component
SetupAdd one fileWrap components yourself
NavigationPrefetched as an instant fallbackNot prefetched by default
Best forPages where nothing can show without the dataMost 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>:

JavaScript
// 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>:

JavaScript
// 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>}
TEXT
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:

JavaScript
// 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:

TEXT
Route (app)      Revalidate  Expire┌ ○ /_not-found└ ◐ /shell               1m      1h○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

The ◐ says: the shell is prebuilt, the rest streams. Then the chunk timeline:

TEXT
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  end

At 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:

TEXT
products fetched at 2026-10-12T03:13:17.430Zproducts fetched at 2026-10-12T03:13:17.430Z

The 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:

  1. You must handle runtime data. I made a page that reads cookies() with no <Suspense> around it. next build failed (the full error is in lesson 5):
TEXT
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 route

So 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").

  1. The old route config is not allowed. I added export const dynamic = 'force-dynamic' to a page. The build failed:
TEXT
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.

  1. Time and random values need a decision. Date.now(), Math.random() and crypto.randomUUID() give a different answer every time, so the new model asks you either to call await 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:

TEXT
HTTP/1.1 200 OKx-nextjs-prerender: 1x-nextjs-postponed: 1Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

x-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:

  1. Does the page need anything from the request (cookie, header, search params, a user)? If not, it can be static, and you are done.
  2. If it does, which small part needs it? Put only that part in a <Suspense> with a fallback of the right size.
  3. Is the slow data the same for everyone? Cache it (use cache in the new model, fetch cache options or revalidate in the default model).
  4. Do you need a real status code (404, redirect)? Decide before the first <Suspense>.
  5. After next build, read the route table. Is each route what you expected?
  6. Check with curl -i. Is Cache-Control what you want a CDN to do?

Common mistakes

  • Testing caching in next dev. Dev renders every page on demand. Use next build and next 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 a 404 status.
  • Expecting export const revalidate = 5 to mean "refresh every 5 seconds on a timer". It means "stale after 5 seconds; the next visit starts a rebuild".
  • Using export const dynamic with cacheComponents on. 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.

  1. Make app/time/page.tsx that prints new Date().toISOString(). Build and start. Ask for it twice with curl. Same time or different? What does the route table show?
  2. 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?
  3. Make two async components that sleep 300 ms and 1500 ms, each in its own <Suspense>. Run curl -N localhost:3000/stream and watch when each piece appears. How long does the whole response take: about 1.8 or 1.5 seconds?
  4. Wrap both in one <Suspense> instead. When does the first piece appear now?
  5. Make a page that calls notFound() before a <Suspense>, and another that calls it inside. Use curl -I on both. What are the status codes?
  6. Add export const revalidate = 5 to a static page that prints the time. Ask three times, four seconds apart, and print the header x-nextjs-cache.
  7. Optional: set cacheComponents: true. What does the build tell you about the pages that read cookies()?

Answers

  1. The same time both times. The route is ○ (static), frozen at build time.
  2. It becomes ƒ and the time changes on every request. connection() makes it request-time.
  3. About 1.5 seconds. Two sibling boundaries start together and resolve on their own.
  4. Not until the slower one is ready (about 1.5 seconds), because one boundary waits for both.
  5. 404 for the one before Suspense. 200 for the one inside it (with a noindex meta tag).
  6. The first answer is STALE or HIT, depending on how old the build is. After 5 seconds, one request returns STALE and starts a rebuild, and the next returns the new copy.
  7. 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-Control and x-nextjs-cache. Static pages say s-maxage=31536000. Dynamic pages say private, no-cache, no-store.
  • Static pages are frozen: my new Date() page gave the same time on every request. Test with build and start, never dev.
  • revalidate = N is 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.tsx is a Suspense boundary for the whole page. Prefer small boundaries near the slow part. Do not await request data at the top of a layout.
  • After streaming starts, the status code is fixed. notFound() inside Suspense gave 200 plus noindex. Decide 404 and 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 old dynamic and revalidate exports.
  • 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

Part of the study guide

Related series