SSR - Rendering Per Request

When and how a Next.js 16 page renders on every request, what the server does for that request, and how per-request rendering differs with and without Cache Components.

Published
Reading time
9 min read
Next.js in PracticePart 22 · 15 of 26
Summary: TL;DR

SSR means the server runs your component tree for each request, with the real cookies(), headers() and searchParams, and streams HTML plus the RSC payload back. For that request, renderAppPage creates a RequestStore from the incoming headers and cookies, and renderToStream runs Flight and Fizz inside it. Every request API then takes its 'request' branch and returns real data. In the legacy model a route becomes SSR as a whole the moment the prerender touches a dynamic API, or when you export dynamic = 'force-dynamic' or revalidate = 0. With Cache Components, request-time work is normally a Suspense hole inside a static shell (PPR). Whole-route SSR is the exception, chosen with instant = false or a <Suspense> above <body>, and shown as ƒ in the build. Either way the response is sent with Cache-Control: private, no-cache, no-store. Inside it you can still cache data with 'use cache', dedupe with React cache, and stream slow parts.

When SSR is the right call

SSR costs a server render per request, and the first byte waits for whatever sits above the first Suspense boundary. It pays off when:

  • The whole page depends on who is asking. A dashboard where even the layout shows account data, or a page whose structure depends on a feature-flag cookie.
  • Nothing useful can be shown first. A search results page where the header is trivial and every result depends on searchParams. A PPR shell with one spinner gains little here.
  • The response must be computed from the request. A/B variant chosen from headers, geo-specific legal text, or a page that must never be cached anywhere.
  • Crawlers need blocking metadata. Next already handles HTML-limited bots by skipping the static shell (see Bots get a blocking render).

If most of the page is the same for everyone, prefer PPR (part 23). If the data only exists in the browser, prefer CSR (part 24).

How a route becomes per-request

Legacy model: one dynamic read deopts the route

Without Cache Components the prerender runs in a 'prerender-legacy' store. Any of these throws a DynamicServerError and the route is recorded as revalidate: 0 (ƒ):

  • cookies(), headers(), draftMode(), connection()
  • await searchParams
  • fetch(..., { cache: 'no-store' }) or next: { revalidate: 0 }
  • export const revalidate = 0

The interrupt itself is throwToInterruptStaticGeneration. It sets prerenderStore.revalidate = 0, records the expression as dynamicUsageDescription, and throws.1 The legacy markCurrentScopeAsDynamic (used by no-store fetches) does the same for prerender-legacy, and throws a StaticGenBailoutError instead when the route exports dynamic = "error".2

You can also ask for SSR up front:

TSX
// app/dashboard/page.tsx (legacy)
export const dynamic = 'force-dynamic'

During a prerender, createComponentTree sees force-dynamic, sets workStore.forceDynamic and throws a DynamicServerError ("won't be rendered statically").3 The build also sets the route's revalidate to 0 when it reads that config.4 At request time forceDynamic short-circuits markCurrentScopeAsDynamic, because "the entire page is already dynamic".2

Warning: Legacy SSR is all-or-nothing

One cookies() call in a shared layout makes every page under it SSR. That is the main reason Cache Components exists. If you are staying on the legacy model, keep request reads in the leaves, or move them into Client Components.

Cache Components: SSR is an explicit choice

With Cache Components, the same cookies() call does not change the route. It returns a hanging promise during the prerender, and its subtree becomes a hole (part 20). If that read is not inside a <Suspense> boundary, the build reports a blocking route. So whole-route SSR has to be requested. There are two ways:

TSX
// app/dashboard/layout.tsx (Cache Components)
// Opt this segment out of instant validation: it may block on request data.
export const instant = false

export default async function DashboardLayout({ children }: LayoutProps<'/dashboard'>) {
  const session = await getSession() // reads cookies(), not wrapped in Suspense
  return <Shell user={session.user}>{children}</Shell>
}

instant = false makes isPageAllowedToBlock return true. prerenderToStream then sets allowEmptyStaticShell, so throwIfDisallowedDynamic returns early instead of failing.567 The prerender produces an empty shell, and the build marks the route ƒ. An empty static shell "is equivalent to a dynamic page".8

The second way is structural. A <Suspense> boundary above <body> in the root layout is read as "this shell is allowed to be empty". The validation code records it as hasSuspenseAboveBody and treats it like the opt-in.9

WantCache ComponentsLegacy
One region per request<Suspense> around a component that reads request data or calls connection()Not possible on the server; use a Client Component
Whole segment per request, may blockexport const instant = falseexport const dynamic = 'force-dynamic'
Per request but no request data usedawait connection() inside Suspenseexport const dynamic = 'force-dynamic' or connection()

connection(): dynamic without reading the request

Sometimes a value must be computed per request but does not come from the request: a random A/B bucket, the current time, a request id. connection() says "from here on, this is request time":

TSX
// app/lottery/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'

async function Ticket() {
  await connection() // never resolves during the prerender
  return <p>Your number: {Math.floor(Math.random() * 1000)}</p>
}

export default function Page() {
  return (
    <Suspense fallback={<p>Drawing…</p>}>
      <Ticket />
    </Suspense>
  )
}

In a Cache Components prerender, connection() returns makeDynamicHangingPromise. In a legacy prerender it calls throwToInterruptStaticGeneration('connection', …).10 With Cache Components this is also the required fix for Math.random(), Date.now() and crypto.randomUUID() outside 'use cache'. See connection() and io() for the behaviour in every work unit.

What the server does for one request

Sequence diagram

Diagram source:

sequenceDiagram
  participant B as Browser
  participant H as app-page handler
  participant R as renderAppPage
  participant F as Flight (RSC)
  participant Z as Fizz (HTML)
  B->>H: GET /dashboard
  H->>H: not SSG, or no cached entry
  H->>R: render with real request
  R->>R: createRequestStoreForRender()
  R->>F: render RSC tree in RequestStore
  F->>Z: RSC stream (tee)
  Z-->>B: HTML shell up to first Suspense
  F-->>Z: resolved boundaries
  Z-->>B: streamed chunks + inline RSC

The RequestStore

renderAppPage binds createRequestStoreForRender with the request, response, URL, root params and implicit tags, then creates the store.11 The store is built from req.headers and an onUpdateCookies callback that writes Set-Cookie to the response.12 It holds the url, read-only headers, cookies and mutableCookies (writable during Server Actions).13

From here every request API takes its 'request' branch. For cookies() that branch tracks the dynamic access for dev tooling and returns the real cookies. In a Server Action phase it returns the mutable ones.14

Streaming the response

renderAppPage calls renderToStream with that store.15 For a regular dynamic render, renderToStream runs the Fizz HTML renderer over the RSC stream inside workUnitAsyncStorage.run(requestStore, …). It then pipes the result through continueFizzStream, which injects the inline RSC payload, server-inserted HTML (CSS-in-JS, metadata) and the bootstrap scripts.16

waitForAllReady is supportsDynamicResponse !== true.17 For a real dynamic request that is false, so the response streams. The HTML up to the first unresolved <Suspense> boundary goes out first, and later boundaries arrive as chunks. For a static generation, the whole document is buffered so it can be stored.

That is why loading.tsx and <Suspense> still matter in SSR. They do not make anything static, but they decide how early the first byte goes out. See Data Fetching and Streaming for the streaming patterns, and React Concepts: priorities for why React can reveal boundaries in any order. Once the first byte is out, the status code is fixed: see how a thrown notFound() travels.

Headers on an SSR response

A dynamic page response carries revalidate: 0. getCacheControlHeader turns that into private, no-cache, no-store, max-age=0, must-revalidate.18 CDNs and browsers will not store it. If you want edge caching for a per-request page, it is not SSR. Make it ISR or PPR.

Caching inside an SSR page

"Rendered per request" does not mean "fetch everything per request". Three tools keep SSR cheap:

TSX
// app/dashboard/page.tsx (Cache Components)
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cache } from 'react'
import { cacheLife, cacheTag } from 'next/cache'

export const instant = false // whole dashboard is per user

// 1. Per-request dedupe: many components can call this, the DB is hit once
const getSession = cache(async () => {
  const token = (await cookies()).get('session')?.value
  return token ? verifySession(token) : null
})

// 2. Cross-request cache keyed by an argument extracted from the request
async function getTeamStats(teamId: string) {
  'use cache'
  cacheLife('minutes')
  cacheTag(`team:${teamId}`)
  return db.stats.forTeam(teamId)
}

export default async function Dashboard() {
  const session = await getSession()
  if (!session) return <SignIn />
  return (
    <>
      <h1>Hello {session.user.name}</h1>
      {/* 3. Stream the slow part even in SSR */}
      <Suspense fallback={<StatsSkeleton />}>
        <Stats teamId={session.teamId} />
      </Suspense>
    </>
  )
}

async function Stats({ teamId }: { teamId: string }) {
  const stats = await getTeamStats(teamId)
  return <StatsGrid stats={stats} />
}
  • React cache dedupes within one render. It is the basis of the Data Access Layer in part 27.
  • 'use cache' with arguments shares results across requests. You cannot call cookies() inside it: the 'cache' branch of cookies() throws.19 Read the cookie outside and pass the value in. It becomes part of the cache key.
  • 'use cache: private' caches a function that reads cookies directly, in the browser only (part 05).

In the legacy model the equivalents are fetch with cache: 'force-cache' / next.revalidate, and unstable_cache. They keep working inside a dynamic route, because the data cache and the page are separate.

Bots get a blocking render

PPR streams metadata after the shell. Some crawlers only read the initial <head>. Next checks the user agent against htmlLimitedBots. When a PPR route is requested by such a bot, shouldForceDynamicPPRRender is set and isSSG becomes false, so the bot gets a fresh blocking render with metadata in the head.20 The metadata side is in Streaming metadata. This is automatic SSR you did not ask for. It is also why the data your prerender used at build time must be reachable at runtime, or those bots get a 500.

Common mistakes

Bug: "My page is ƒ and I don't know why" (legacy)

Run next build --debug-prerender (do not deploy that build), or read the build log. The prerender records dynamicUsageDescription, for example "Route /blog couldn't be rendered statically because it used cookies", and the export step logs it with the stack.21 The usual culprit is a header component or analytics helper in the root layout.

Bug: "instant = false everywhere"

It silences validation and removes the shell. Use it on the segment that truly blocks (often an authenticated layout), and keep instant validation on the pages below so their navigations stay instant. A child can still be validated when the parent sets false.

Bug: Reading searchParams in a layout

Layouts do not receive searchParams. Reading the query in a page is fine, but in the legacy model it makes the page SSR. With Cache Components, put the reading component inside Suspense so the rest of the page stays in the shell.

Bug: Expecting Date.now() to be per request

In a legacy static page it is frozen at build time. With Cache Components it is an error until you put it after connection() or inside 'use cache'.

Summary

SSR is a full server render per request. renderAppPage builds a RequestStore from the incoming request, so cookies(), headers() and searchParams take their 'request' branch. renderToStream then streams Fizz HTML with the inline RSC payload, flushing at each Suspense boundary. In the legacy model any dynamic read during the prerender throws and turns the whole route into SSR, and force-dynamic or revalidate = 0 force it. With Cache Components, request-time work is a Suspense hole by default. Whole-route SSR is opted into with instant = false or Suspense above <body>, which allows an empty shell (ƒ). Keep SSR cheap with React cache, 'use cache' keyed by values you read outside it, and streaming. When most of the page is shared, the better tool is PPR.

Key takeaways

  • Request store: createRequestStoreForRender wraps the request's headers and cookies, and every request API reads real data from it.
  • Streaming by default: waitForAllReady is off for dynamic responses, so HTML flushes at each Suspense boundary.
  • Legacy deopt: one dynamic read in a prerender throws DynamicServerError and makes the whole route ƒ.
  • force-dynamic: legacy only; it sets forceDynamic, throws during prerender, and sets the route's revalidate to 0.
  • Cache Components opt-in: instant = false or Suspense above <body> allows an empty shell; otherwise an unwrapped request read is a build error.
  • connection(): marks request time without reading the request; required before random values and timestamps.
  • Not cacheable: SSR responses are sent with private, no-cache, no-store.
  • Cache inside: dedupe with React cache, share with 'use cache' arguments, never call cookies() inside 'use cache'.
  • Bots: HTML-limited crawlers skip the PPR shell and get a blocking render.

← SSG & ISR · Index · PPR →

Footnotes

  1. throwToInterruptStaticGeneration — packages/next/src/server/app-render/dynamic-rendering.ts#L159-L175 (opens in a new tab) ↩

  2. markCurrentScopeAsDynamic — packages/next/src/server/app-render/dynamic-rendering.ts#L91-L151 (opens in a new tab) ↩ ↩2

  3. dynamic segment config in createComponentTree — packages/next/src/server/app-render/create-component-tree.tsx#L304-L336 (opens in a new tab) ↩

  4. force-dynamic sets revalidate = 0 without PPR — packages/next/src/build/utils.ts#L981-L986 (opens in a new tab) ↩

  5. isPageAllowedToBlock — packages/next/src/server/app-render/instant-validation/instant-config.tsx#L80-L110 (opens in a new tab) ↩

  6. allowEmptyStaticShell — packages/next/src/server/app-render/app-render.tsx#L8929-L8931 (opens in a new tab) ↩

  7. throwIfDisallowedDynamic — packages/next/src/server/app-render/dynamic-rendering-validation.ts#L838-L907 (opens in a new tab) ↩

  8. getTreeViewSymbol — packages/next/src/build/utils.ts#L236-L277 (opens in a new tab) ↩

  9. trackDynamicHoleInStaticShell (hasSuspenseAboveBody) — packages/next/src/server/app-render/dynamic-rendering-validation.ts#L718-L784 (opens in a new tab) ↩

  10. connection() — packages/next/src/server/request/connection.ts#L79-L104 (opens in a new tab) ↩

  11. renderAppPage creates the request store — packages/next/src/server/app-render/app-render.tsx#L2975-L2990 (opens in a new tab) ↩

  12. createRequestStoreForRender — packages/next/src/server/async-storage/request-store.ts#L184-L219 (opens in a new tab) ↩

  13. RequestStore — packages/next/src/server/app-render/work-unit-async-storage.external.ts#L36-L60 (opens in a new tab) ↩

  14. cookies() 'request' branch — packages/next/src/server/request/cookies.ts#L107-L138 (opens in a new tab) ↩

  15. renderAppPage → renderToStream — packages/next/src/server/app-render/app-render.tsx#L3134-L3150 (opens in a new tab) ↩

  16. regular dynamic render in renderToStream (web streams) — packages/next/src/server/app-render/app-render.tsx#L4344-L4402 (opens in a new tab) ↩

  17. waitForAllReady — packages/next/src/server/app-render/app-render.tsx#L3599 (opens in a new tab) ↩

  18. getCacheControlHeader — packages/next/src/server/lib/cache-control.ts#L17-L35 (opens in a new tab) ↩

  19. cookies() inside 'use cache' throws — packages/next/src/server/request/cookies.ts#L63-L70 (opens in a new tab) ↩

  20. serveStreamingMetadata, shouldForceDynamicPPRRender, isSSG — packages/next/src/build/templates/app-page-runtime.ts#L596-L616 (opens in a new tab) ↩

  21. exportAppPage dynamic-usage catch and warning — packages/next/src/export/routes/app-page.ts#L287-L312 (opens in a new tab) ↩