Server-Side Rendering

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router in depth and the Pages Router (getServerSideProps) side by side, because the two routers solve the same problem with very different machinery. Caching matters here, so every example says which model it uses: the previous model (the default: nothing is cached unless you ask) or Cache Components (the opt-in cacheComponents: true model, in which everything is dynamic unless you cache it and a static shell is prerendered). Every example was built with next build and served with next start (Node.js 24.16, macOS) in a scratch copy of the Bookshelf — the catalogue of books, authors and reviews that this section grows — and the results quoted below are what curl returned.

Server-side rendering (SSR) means that the HTML of a page is produced on the server for each request, after the request has arrived, so the output can depend on who is asking and on what the data is right now. This page explains how Next.js decides that a route needs it, what the server sends back, how the response is streamed, and what it costs. It builds on Routing Fundamentals and Layouts, Templates and Special Files; the rendering spectrum (SSR next to SSG, ISR, PPR and CSR) is compared on the Rendering Fundamentals page, and Server Components, streaming and Suspense have their own pages (Server and Client Components, Streaming and Suspense), which are only summarised here. For the React ideas underneath, see Server Rendering, SSG and React Server Components.

What SSR Is and Why You Use It

By default Next.js renders a route once, ahead of time, and serves the result as many times as it is asked for. That is prerendering, and it is the default for any component that does not use a Request-time API. SSR is the opposite decision: render when the request arrives. The official glossary calls it dynamic rendering (also "runtime rendering"): "when a component is rendered at request time rather than build time", which happens when it uses a Request-time API or, in the previous model, uncached data.

Table 1. The vocabulary, mapped to what you will see in the documentation
Term Meaning Where it appears

Server-side rendering (SSR)

HTML is generated on the server for each request.

Everyday name; the Pages Router docs title it "Server-side Rendering" and say it is also referred to as "Dynamic Rendering".

Dynamic rendering

An App Router route (or the dynamic part of one) is rendered at request time.

App Router glossary; the build route table marks it ƒ (Dynamic) or ◐ (Partial Prerender).

Prerendering

HTML and RSC payload produced at build time, or in the background on revalidation.

Default for components that read no request data; the opposite of SSR.

Request-time APIs

Functions and props that read request-specific data and therefore opt the component into dynamic rendering.

cookies(), headers(), searchParams, draftMode(), and in practice connection().

Server Component

A component that runs only on the server, can be async, and ships no JavaScript to the browser.

The unit that does the rendering in the App Router.

Use SSR when the output depends on information that exists only when the request arrives or that changes faster than you can rebuild:

  • Per-user content: a shelf, a cart or an account page that reads a session cookie.

  • Per-request context: the Accept-Language or geolocation headers, the query string of a search page, an Authorization header.

  • Data that must be fresh on every view: stock levels, prices, a live score.

Prefer prerendering when the same HTML is right for everyone; it is cheaper, can be served from a CDN and has no server work on the hot path. The practical modern answer is rarely "SSR for the whole page": put the personalised or fast-changing part under a <Suspense> boundary so the rest of the page can be served immediately (see the Streaming and Suspense page).

How a Route Becomes Dynamic

Next.js does not ask you to declare "this page is SSR". It renders the route tree and notices what it touches. The route becomes dynamic when something in the tree needs request data or data that must not be reused.

Table 2. What makes a route dynamic in Next.js 16.3 (checked against the build route table)
Trigger Effect Verified result in the scratch app

await cookies(), await headers()

Reading request cookies or headers needs a real request.

Route shown as ƒ (previous model).

await searchParams (the page prop)

The query string exists only per request.

ƒ.

await params of a segment without generateStaticParams

Unknown values are resolved at request time.

ƒ (previous model); see the Cache Components section for the extra <Suspense> rule.

await connection()

Waits for a real request before continuing; the way to ask for per-request output when nothing else is request-dependent (new Date(), Math.random()).

ƒ.

fetch(url, { cache: "no-store" })

The previous model: an uncached fetch after a Request-time API runs on every request; no-store makes the intent explicit.

ƒ.

export const dynamic = "force-dynamic"

Forces dynamic rendering for the segment (previous model only).

ƒ; with Cache Components the build fails (see below).

export const revalidate = 0

Documented as "always dynamically rendered" in the previous model.

Not run here; stated by the official guide.

await draftMode()

Documented as a Request-time API; it reports whether the bypass cookie of Draft Mode is present.

The page stayed ○ (static) in the build route table; with the __prerender_bypass cookie the request rendered per request and returned the unpublished branch.

unstable_noStore()

Legacy: declaratively opts a component out of prerendering.

Deprecated in 15 in favour of connection(); not recommended.

The build output tells you what you got. This is the route table for the experiments on this page (previous model); the ƒ rows are the server-rendered ones, and the Route (pages) block is the Pages Router.

next build route table (previous model, trimmed)
Route (app)
├ ƒ /ssr/after
├ ƒ /ssr/api/shelf
├ ƒ /ssr/books/[id]
├ ƒ /ssr/client
├ ƒ /ssr/clock
├ ○ /ssr/draft
├ ƒ /ssr/draft/enable
├ ƒ /ssr/fd
├ ƒ /ssr/nostore
└ ƒ /ssr/shelf

Route (pages)
├ ƒ /legacy/books/[id]
└ ƒ /legacy/shelf

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

Source: next build

The decision, as a flowchart:

flowchart TD A["Next.js renders the route tree"] --> B{"Does a component read
cookies, headers, searchParams
or await connection or io?"} B -->|yes| C["Dynamic: this work waits for the request"] B -->|no| D{"Uncached data?
fetch with no-store, database call"} D -->|"previous model: yes, after a Request-time API"| C D -->|"Cache Components: yes"| C D -->|no| E["Prerendered at build time
or on revalidation"] C --> F{"Which model?"} F -->|"previous model"| G["The whole route renders per request
unless the data is cached"] F -->|"Cache Components"| H["Static shell prerendered,
this subtree streams per request
inside its Suspense boundary"]
Figure 1. Does this part of the route render at request time?

The Request-time APIs and Why They Are Async

In Next.js 14 cookies(), headers() and draftMode() returned their values synchronously, and params and searchParams were plain objects. In Next.js 15 they became promises (cookies, headers and draftMode are async functions; params and searchParams are Promise props), and in Next.js 16 the temporary synchronous access was removed. The reason is architectural: a promise lets the renderer start work that does not need the request (the layout, the static markup, a cached query) and only wait where the request is actually needed. A synchronous cookies() call would have forced the whole route to wait for the request before rendering anything. The same mechanism is what lets Cache Components prerender a static shell and stream the dynamic holes.

Table 3. The Request-time APIs and how to read them in a Server Component
API Read it with Notes

cookies() from next/headers

const jar = await cookies() then jar.get("shelf")?.value

Read-only while rendering; set and delete only in a Server Function or Route Handler.

headers() from next/headers

const hdrs = await headers() then hdrs.get("accept-language")

Read-only; the standard Web Headers object.

searchParams prop of page

const { sort } = await searchParams

Only pages receive it (not layouts). Values are string, string[] or undefined.

params prop of page, layout, route

const { id } = await params

Dynamic only when the segment is not covered by generateStaticParams.

draftMode() from next/headers

const { isEnabled } = await draftMode()

enable() and disable() work in a Route Handler and set or delete the __prerender_bypass cookie. A new bypass value is generated for each next build.

connection() from next/server

await connection()

Returns Promise<void> that you do not consume; everything after it runs only for a real request.

io() from next/cache (16.3, Cache Components)

await io()

Like connection() for synchronous sources (new Date(), Math.random(), crypto.randomUUID()), but can also be cached and prefetched; resolves immediately outside Cache Components and in the Pages Router.

connection() and io() exist for output that varies without reading any request data. Without them, a page that calls new Date() is rendered once at build time and the timestamp is frozen into the HTML.

A page that must differ on every request (previous model)
// app/ssr/clock/page.tsx
import { connection } from "next/server";

export default async function ClockPage() {
  await connection(); // prerendering stops here; the next lines run per request
  const now = new Date().toISOString();
  const token = Math.random().toString(36).slice(2, 8);
  return (
    <p>
      Rendered at {now} (render id {token})
    </p>
  );
}

Two requests a few milliseconds apart returned different timestamps and render ids, and the route is ƒ in the build table. Source: connection

Worked Example: the Bookshelf "My Shelf" Page

The Bookshelf keeps each reader’s shelf (the books they saved) in a shelf cookie, a comma separated list of book ids. The "My shelf" page must show that reader’s books, in the reader’s language, sorted by a query parameter. All three inputs exist only per request, so the page is server-rendered. The catalogue comes from lib/books.ts (marked server-only, from the earlier pages).

app/ssr/shelf/page.tsx (previous model)
import { cookies, headers } from "next/headers";
import { listBooks } from "@/lib/books";

export default async function ShelfPage({ searchParams }: PageProps<"/ssr/shelf">) {
  const { sort } = await searchParams; // Request-time API (async since 15)
  const jar = await cookies(); // Request-time API
  const hdrs = await headers(); // Request-time API

  const language = hdrs.get("accept-language")?.slice(0, 2) ?? "en";
  const ids = (jar.get("shelf")?.value ?? "").split(",").filter(Boolean);
  const books = (await listBooks()).filter((b) => ids.includes(b.id));
  if (sort === "title") books.sort((a, b) => a.title.localeCompare(b.title));

  return (
    <main lang={language}>
      <h1>My shelf ({books.length})</h1>
      <ul>
        {books.map((b) => (
          <li key={b.id}>{b.title}</li>
        ))}
      </ul>
    </main>
  );
}

PageProps<"/ssr/shelf"> is the globally available typed helper that Next.js generates for the route (its searchParams is Promise<Record<string, string | string[] | undefined>>). Source: page and cookies

Run against next start:

Requesting the page with a cookie, a language and a query string
curl -si -H 'cookie: shelf=1,3' -H 'accept-language: es-ES' 'http://localhost:3000/ssr/shelf?sort=title'
Response (trimmed): the status line, two headers and the markup
HTTP/1.1 200 OK
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
...
<main lang="es"><h1>My shelf (<!-- -->2<!-- -->)</h1><ul><li>Dune</li><li>Solaris</li></ul></main>

Without the cookie the same URL returned My shelf (0). Two things are worth reading in that response. First, Next.js itself sets Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate on a dynamically rendered page, so no shared cache will keep one reader’s shelf for another. Second, the Vary header lists the request headers that distinguish an HTML request from an RSC request for the same URL, which matters when a CDN sits in front.

Dynamic Rendering in Both Caching Models

The same page needs a different shape when cacheComponents: true is on. The previous model renders the whole route per request as soon as one component reads request data. Cache Components inverts the default: everything is dynamic unless cached, Next.js prerenders a static shell, and it insists that request data is read inside a <Suspense> boundary so that the shell can be sent without waiting for it.

Table 4. The two models side by side
Previous model (default) Cache Components (cacheComponents: true)

Request data (cookies, headers, searchParams, params)

Reading it makes the whole route dynamic (ƒ).

Must be read under <Suspense>; the rest of the route is the static shell (◐ Partial Prerender).

Uncached data

fetch is not cached by default; one uncached call after a Request-time API runs on every request.

Dynamic by default; cache with "use cache" where you want it static.

Synchronous per-request values (new Date())

await connection() (or any Request-time API) first.

await io() (16.3) or connection() under <Suspense>; a synchronous read during prerender is a build error.

export const dynamic = "force-dynamic"

Works.

Rejected at build time; "all pages are dynamic by default", so remove it.

What the user receives first

Nothing until the whole route has rendered (unless you add <Suspense>).

The static shell with the fallbacks, then the dynamic chunks.

Here is the same shelf under Cache Components. The page itself is synchronous and renders the heading as part of the static shell; the data-reading component sits under <Suspense>, and it receives the searchParams promise without awaiting it.

app/ssr/shelf/page.tsx (Cache Components)
import { Suspense } from "react";
import { cookies, headers } from "next/headers";
import { listBooks } from "@/lib/books";

export default function ShelfPage({ searchParams }: PageProps<"/ssr/shelf">) {
  return (
    <main>
      <h1>My shelf</h1> {/* part of the static shell */}
      <Suspense fallback={<p>Loading your shelf...</p>}>
        <Shelf searchParams={searchParams} />
      </Suspense>
    </main>
  );
}

async function Shelf({ searchParams }: { searchParams: PageProps<"/ssr/shelf">["searchParams"] }) {
  const { sort } = await searchParams; // runtime data: only this subtree is dynamic
  const jar = await cookies();
  const language = (await headers()).get("accept-language")?.slice(0, 2) ?? "en";
  const ids = (jar.get("shelf")?.value ?? "").split(",").filter(Boolean);
  const books = (await listBooks()).filter((b) => ids.includes(b.id));
  if (sort === "title") books.sort((a, b) => a.title.localeCompare(b.title));
  return (
    <ul lang={language}>
      {books.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ul>
  );
}

Source: Caching and cacheComponents

The build marks the route ◐ /ssr/shelf (Partial Prerender). The response is chunked: the first part contains the prerendered shell with the fallback, and the dynamic result arrives as a second chunk.

First chunk (the static shell) and the later chunk, with cookie: shelf=1,3 and accept-language: es
<main><h1>My shelf</h1> <!--$?--><template id="B:0"></template><p>Loading your shelf...</p><!--/$--></main>
...
<div hidden id="S:0"><ul lang="es"><li>Dune</li><li>Solaris</li></ul></div>

If you read the request data outside <Suspense>, the build stops. Removing the boundary from the after() example below produced exactly this message:

The Cache Components build error
Error: Route "/ssr/after": 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"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

For values that are not request data, io() is the Cache Components counterpart of connection():

A per-request timestamp under Cache Components (16.3)
import { Suspense } from "react";
import { io } from "next/cache";

export default function ClockPage() {
  return (
    <Suspense fallback={<p>Reading the clock...</p>}>
      <Clock />
    </Suspense>
  );
}

async function Clock() {
  await io(); // keep this out of the static shell; resolves immediately at request time
  return <p>Rendered at {new Date().toISOString()}</p>;
}

The shell contains the fallback Reading the clock…​, and two requests one second apart showed timestamps one second apart. Source: io

force-dynamic in the previous model, and its removal

Forcing a segment dynamic (previous model only)
// app/ssr/fd/page.tsx
export const dynamic = "force-dynamic";

export default function Page() {
  return <p>Always rendered per request</p>;
}

The build table shows ƒ /ssr/fd. With cacheComponents: true, the same file fails the build with Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it. Prefer the granular triggers above to a blanket force-dynamic. Source: Caching and Revalidating (previous model)

The Response: HTML plus the RSC Payload

A server-rendered App Router response is one document that contains two encodings of the same render. React renders the Server Component tree on the server into the RSC payload (a compact, line-based description of the React tree: elements, text, and references to Client Components with their chunk files). From that, React renders the HTML, and Next.js appends the payload to the document in <script> tags that call self.__next_f.push(…​). The browser paints the HTML immediately, then React downloads the Client Component code, reads the inline payload and hydrates the page, attaching event handlers to the existing markup instead of rebuilding it.

sequenceDiagram participant B as Browser participant P as Proxy participant N as Next.js server participant R as React renderer participant D as Data source B->>P: GET /ssr/shelf with Cookie and Accept-Language P->>N: pass through, or redirect or rewrite N->>R: render the route tree for this request R->>D: await cookies, headers, searchParams, data D-->>R: rows R-->>N: HTML chunks and RSC payload N-->>B: 200 with Cache-Control, HTML streamed N-->>B: inline RSC payload scripts B->>B: paint HTML, download JS, hydrate Client Components Note over N: after callbacks run once the response is finished B->>N: later navigation sends RSC 1 and gets only the payload
Figure 2. One SSR request, from the browser to hydration

Look at both encodings of the shelf response. The HTML carries the visible markup; the payload carries the same elements as data, which is what React uses to hydrate and what a client-side navigation downloads instead of HTML.

The same list in the HTML and in the inline RSC payload (trimmed)
<li>Dune</li><li>Solaris</li>                                   <- HTML

self.__next_f.push([1,"...4:[\"$\",\"main\",null,{\"lang\":\"es\",\"children\":[[\"$\",\"h1\",null,
{\"children\":[\"My shelf (\",2,\")\"]}],[\"$\",\"ul\",null,{\"children\":[[\"$\",\"li\",\"1\",
{\"children\":\"Dune\"}],[\"$\",\"li\",\"3\",{\"children\":\"Solaris\"}]]}]]}]..."])    <- payload

When the user later navigates with next/link, the browser sends the request with an RSC: 1 header and receives only the payload, with content type text/x-component. Next.js adds a cache-busting _rsc query parameter and redirects a request that lacks the right one, so curl needs -L:

Fetching the payload by hand
curl -siL -H 'RSC: 1' -H 'cookie: shelf=1,2' 'http://localhost:3000/ssr/shelf?sort=title'
Response (trimmed)
HTTP/1.1 307 Temporary Redirect
location: /ssr/shelf?sort=title&_rsc

HTTP/1.1 200 OK
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/x-component

...
4:["$","main",null,{"lang":"en","children":[["$","h1",null,{"children":["My shelf (",2,")"]}], ...

The format is an implementation detail; this is for understanding, not for parsing. The mechanics of the payload and of 'use client' boundaries belong to the Server and Client Components page, and next/link navigation to Linking and Navigation. Source: Server and Client Components

Client Components are server-rendered too

A 'use client' component is not skipped on the server. On the first load the server renders it to HTML like everything else (that is the "SSR" in "SSR and hydrate"), and the browser then hydrates it. Only on later navigations is it rendered purely on the client. The shelf can hand a server-read value to a Client Component as a serialisable prop:

A Client Component whose first render happens on the server
// app/ssr/client/page.tsx (Server Component)
import { cookies } from "next/headers";
import { Counter } from "./counter";

export default async function Page() {
  const jar = await cookies();
  return <Counter initial={Number(jar.get("count")?.value ?? 0)} />;
}

// app/ssr/client/counter.tsx
"use client";

import { useState } from "react";

export function Counter({ initial }: { initial: number }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

With cookie: count=5 the HTML already contained <button>Count: <!-- -→5<!-- -→</button> before any JavaScript ran. Because the same component runs on the server and then again during hydration, its first render must produce the same markup in both places; branching on typeof window or on Date.now() during render makes the two differ and causes a hydration mismatch (see the pitfalls). Source: Server and Client Components and hydrateRoot

React’s Streaming APIs, and Node Streams in 16.3

Underneath, Next.js uses React’s server rendering functions, which stream: React emits the HTML for everything that is ready, leaves a placeholder for each suspended <Suspense> boundary, and sends the missing HTML later together with a tiny inline script that swaps it in. React ships two entry points for this:

  • renderToPipeableStream(reactNode, options) for Node.js, which returns { pipe, abort } and calls onShellReady, onAllReady, onShellError and onError as the render progresses.

  • renderToReadableStream(reactNode, options) for runtimes with Web Streams (Edge, Deno, Bun), which returns a promise of a ReadableStream.

You do not call either one in an App Router application; Next.js does. They matter because they explain the behaviour you observe: a shell that arrives first, chunks that arrive later, and a status code that cannot change after the first byte. This script (outside Next.js, using only react and react-dom/server) makes it visible by rendering a page whose Reviews component suspends for 200 ms:

Both APIs, side by side (run with node streams.mjs)
import { Writable } from "node:stream";
import { createElement as h, Suspense } from "react";
import { renderToPipeableStream, renderToReadableStream } from "react-dom/server";

// A component that suspends for 200 ms, like a Server Component awaiting data.
function makeApp() {
  let state = "new";
  let value;
  const promise = new Promise((r) => setTimeout(r, 200)).then(() => {
    state = "done";
    value = "3 reviews";
  });
  function Reviews() {
    if (state !== "done") throw promise;
    return h("p", null, value);
  }
  return h(
    "main",
    null,
    h("h1", null, "Dune"),
    h(Suspense, { fallback: h("p", null, "loading reviews") }, h(Reviews)),
  );
}

// 1. Node.js streams (what Next.js 16.3 now uses for App Router SSR).
const t0 = Date.now();
await new Promise((done) => {
  const { pipe } = renderToPipeableStream(makeApp(), {
    onShellReady() {
      console.log(`[pipeable] onShellReady after ${Date.now() - t0} ms`);
      pipe(
        new Writable({
          write(chunk, _enc, cb) {
            console.log(`[pipeable] ${Date.now() - t0} ms: ${String(chunk).slice(0, 70)}`);
            cb();
          },
          final(cb) {
            cb();
            done();
          },
        }),
      );
    },
    onAllReady() {
      console.log(`[pipeable] onAllReady after ${Date.now() - t0} ms`);
    },
  });
});

// 2. Web streams (Edge, Deno, Bun, and what Next.js used before 16.3).
const t1 = Date.now();
const stream = await renderToReadableStream(makeApp());
console.log(`[readable] shell promise resolved after ${Date.now() - t1} ms`);
const reader = stream.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(`[readable] ${Date.now() - t1} ms: ${decoder.decode(value).slice(0, 70)}`);
}
Output
[pipeable] onShellReady after 2 ms
[pipeable] 3 ms: <main><h1>Dune</h1><!--$?--><template id="B:0"></template><p>loading r
[pipeable] onAllReady after 202 ms
[pipeable] 202 ms: <div hidden id="S:0"><p>3 reviews</p></div><script>$RB=[];$RV=function
[readable] shell promise resolved after 2 ms
[readable] 3 ms: <main><h1>Dune</h1><!--$?--><template id="B:0"></template><p>loading r
[readable] 201 ms: <div hidden id="S:0"><p>3 reviews</p></div><script>$RB=[];$RV=function

The shell (heading plus fallback) is ready after about 2 ms; the real content follows at about 200 ms in a second chunk. onShellReady is the moment a server may start sending and must already know the status code (set a 200, or a 500 from onShellError); onAllReady fires when every boundary has resolved, which is what you would wait for when serving a crawler or generating static output. Source: renderToPipeableStream, renderToReadableStream and Suspense

What changed in 16.3: the 16.3 release notes say the App Router rendering layer now uses native Node.js streams instead of converting Web Streams during server-side rendering. The Next.js team reports up to 22 percent more requests handled under load in its own benchmark, with no application change. Treat that as a vendor benchmark for SSR throughput on Node.js, not a promise for your workload. Source: Next.js 16.3

Status Codes and Headers

In the Pages Router you call res.statusCode = …​ and res.setHeader(…​). A Server Component has no res object; the App Router gives you one tool per concern, and the stream sets one rule: the status line and headers are sent with the first byte.

Table 5. Choosing the status code and headers in the App Router
You want Use Result (verified with curl -i)

404

notFound() from next/navigation, called before anything is streamed.

/ssr/books/99 returned 404; Next.js also injects <meta name="robots" content="noindex">.

307 redirect

redirect(path) from next/navigation.

/ssr/books/dune returned 307 with location: /ssr/books/1.

308 redirect

permanentRedirect(path).

/ssr/books/old-dune returned 308.

Any status, any headers, any body

A Route Handler (route.ts) returning a Response.

/ssr/api/shelf returned the Cache-Control and X-Shelf-Size headers set in code.

Static headers for a path pattern

headers() in next.config.ts.

X-Robots-Tag: noindex appeared on /ssr/shelf.

Headers, redirects or rewrites before rendering

proxy.ts (see Redirects, Rewrites and Proxy).

Runs before the route, so real status codes remain available.

A cookie

cookies().set(…​) in a Server Function or Route Handler.

Calling it while rendering a page throws.

A book page with real status codes (previous model)
// app/ssr/books/[id]/page.tsx
import { notFound, permanentRedirect, redirect } from "next/navigation";
import { getBook, listReviews } from "@/lib/books";

export default async function BookPage({ params }: PageProps<"/ssr/books/[id]">) {
  const { id } = await params;
  if (id === "dune") redirect("/ssr/books/1"); // 307, sent before any HTML
  if (id === "old-dune") permanentRedirect("/ssr/books/1"); // 308
  const book = await getBook(id);
  if (!book) notFound(); // 404 because nothing has been streamed yet
  const reviews = await listReviews(id);
  return (
    <article>
      <h1>{book.title}</h1>
      <p>{(book.priceCents / 100).toFixed(2)} EUR, {reviews.length} reviews</p>
    </article>
  );
}
Results
/ssr/books/1        -> 200
/ssr/books/99       -> 404
/ssr/books/dune     -> 307 /ssr/books/1
/ssr/books/old-dune -> 308 /ssr/books/1

Source: not-found and redirect

The status code is only honest when it is decided before streaming starts. Under Cache Components the same page has to read params under <Suspense>, and notFound() is then thrown inside an already-streaming boundary. The test showed exactly what the streaming guide warns about: /ssr/books/99 returned 200 with <meta name="robots" content="noindex"> in the body. If a real 404 matters (crawlers, link checkers), decide it before the boundary, for example in proxy.ts or with generateStaticParams plus a bounded dynamicParams, and see the Streaming and Suspense page for the full HTTP contract. A redirect() thrown mid-stream likewise becomes a client-side redirect rather than an HTTP one. Source: Streaming

Cache-Control on dynamic pages. Next.js sends Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate for a dynamically rendered page, which is the safe default. You can override it with the headers() config, and the override wins; verified:

Overriding Cache-Control for an anonymous, non-personalised path
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/ssr/books/:id",
        headers: [{ key: "Cache-Control", value: "public, s-maxage=10, stale-while-revalidate=59" }],
      },
    ];
  },
};

export default nextConfig;

/ssr/books/1 then answered Cache-Control: public, s-maxage=10, stale-while-revalidate=59, while /ssr/shelf kept the private default. Only do this for output that is identical for every visitor; never for a page that reads cookies. For pages that are really "the same HTML for everyone, refreshed often", prefer prerendering with revalidation (see the Static Site Generation and Incremental Static Regeneration pages) over hand-written s-maxage. Source: headers

Work After the Response: after()

Logging, analytics and cache warming should not delay the HTML. after() from next/server schedules a callback that runs after the response (or prerender) has finished. It is stable since 15.1 (unstable_after in 15.0 RC), works in Server Components (including generateMetadata), Server Functions, Route Handlers and Proxy, and runs even if the response failed, or notFound() or redirect() was called. It is not a Request-time API: calling it does not make a route dynamic.

The one rule that catches people: in a Server Component, request data cannot be read inside the callback (Next.js must know which part of the tree reads request data), so read it first and let the callback close over the values.

after() in a page (previous model)
import { after } from "next/server";
import { headers } from "next/headers";

export default async function AfterPage() {
  // Read request data BEFORE after(): Request-time APIs are not allowed inside its callback here.
  const userAgent = (await headers()).get("user-agent") ?? "unknown";
  const started = Date.now();

  after(() => {
    // Runs after the response has been sent; does not delay it.
    console.log(`[after] page rendered for ${userAgent} in ${Date.now() - started} ms`);
  });

  return <p>Thanks for visiting</p>;
}

curl -A curl-test /ssr/after returned 200 and the server log then printed [after] page rendered for curl-test in 1 ms. Under Cache Components the headers() read moves into a component under <Suspense> (otherwise the build fails with the error shown earlier):

after() under Cache Components
import { Suspense } from "react";
import { after } from "next/server";
import { headers } from "next/headers";

export default function AfterPage() {
  return (
    <Suspense fallback={<p>...</p>}>
      <Visit />
    </Suspense>
  );
}

async function Visit() {
  const userAgent = (await headers()).get("user-agent") ?? "unknown"; // read before after()
  after(() => {
    console.log(`[after] visit by ${userAgent}`);
  });
  return <p>Thanks for visiting</p>;
}

The log printed [after] visit by cc-test. The callback runs for the platform’s default or configured maximum duration (maxDuration route segment config); on a self-hosted Node.js server, shut down with SIGINT or SIGTERM and give the process a drain period so pending after() work finishes. after is not available with a static export. Source: after and Self-hosting: after

Draft Mode

Draft Mode lets an editor see unpublished content on routes that are otherwise prerendered. A Route Handler enables it and a page reads it:

Enabling and reading Draft Mode
// app/ssr/draft/enable/route.ts
import { draftMode } from "next/headers";

export async function GET() {
  const draft = await draftMode();
  draft.enable(); // sets the __prerender_bypass cookie
  return new Response("Draft mode is enabled");
}

// app/ssr/draft/page.tsx
import { draftMode } from "next/headers";

export default async function DraftPage() {
  const { isEnabled } = await draftMode(); // Request-time API
  return <p>{isEnabled ? "Showing unpublished reviews" : "Showing published reviews"}</p>;
}

Without the cookie the page answered from the prerender (Showing published reviews, route ○); with the __prerender_bypass cookie it rendered for that request, returned Showing unpublished reviews and sent Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. Over plain HTTP in local testing, browsers may need to allow third-party cookies for it to work. Source: draftMode and Draft Mode

The Pages Router: getServerSideProps Side by Side

In the Pages Router a page opts into SSR by exporting an async function named getServerSideProps. Next.js calls it on the server for every request, passes its returned props to the page component, renders the page to HTML, and serialises the props into a NEXT_DATA JSON block so the browser can hydrate. The page below is the Pages Router version of the shelf, in pages/legacy/shelf.tsx (the Bookshelf scratch app had no pages/ directory, so one was added; the two routers coexist in one project as long as no URL is claimed by both).

pages/legacy/shelf.tsx (Pages Router)
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";

type Props = { titles: string[]; language: string };

const catalogue: Record<string, string> = { "1": "Dune", "2": "Neuromancer", "3": "Solaris" };

export const getServerSideProps = (async ({ req, query }) => {
  const ids = (req.cookies.shelf ?? "").split(",").filter(Boolean); // cookies are parsed for you
  const language = req.headers["accept-language"]?.slice(0, 2) ?? "en";
  const titles = ids.map((id) => catalogue[id]).filter(Boolean);
  if (query.sort === "title") titles.sort();
  return { props: { titles, language } };
}) satisfies GetServerSideProps<Props>;

export default function Page({ titles, language }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main lang={language}>
      <h1>My shelf ({titles.length})</h1>
      <ul>
        {titles.map((t) => (
          <li key={t}>{t}</li>
        ))}
      </ul>
    </main>
  );
}

curl -si -H 'cookie: shelf=3,1' '/legacy/shelf?sort=title' returned 200, the same private Cache-Control, and <main lang="en"><h1>My shelf (2)</h1><ul><li>Dune</li><li>Solaris</li></ul></main>. Source: getServerSideProps

The context object

getServerSideProps receives one context argument:

Table 6. The context parameter
Key Meaning

params

Route parameters of a dynamic route ({ id: …​ } for [id].tsx).

req

The Node.js IncomingMessage, with an extra parsed cookies object.

res

The Node.js ServerResponse, so you can call setHeader.

query

The query string including dynamic route parameters.

resolvedUrl

The normalised URL, with the _next/data prefix stripped for client transitions.

draftMode

true in Draft Mode (preview and previewData are deprecated in favour of it).

locale, locales, defaultLocale

Present when Pages Router i18n is configured.

Return values: props, redirect, notFound

The function returns exactly one of props, redirect or notFound:

A Pages Router book page with a redirect, a 404 and a Cache-Control header
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";

type Props = { title: string; price: string };

export const getServerSideProps = (async ({ params, res, query, resolvedUrl }) => {
  const id = String(params?.id);
  if (id === "dune") return { redirect: { destination: "/legacy/books/1", permanent: false } };
  const data = { "1": ["Dune", 999], "2": ["Neuromancer", 899], "3": ["Solaris", 799] }[id];
  if (!data) return { notFound: true };

  // Per-response headers are set on the Node response object.
  res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");

  console.log("[gSSP]", resolvedUrl, JSON.stringify(query));
  return {
    props: {
      title: String(data[0]),
      price: (Number(data[1]) / 100).toFixed(2),
    },
  };
}) satisfies GetServerSideProps<Props>;

export default function Page({ title, price }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      <h1>{title}</h1>
      <p>{price} EUR</p>
    </main>
  );
}
Results (previous model, next start)
/legacy/books/1?x=1   -> 200   Cache-Control: public, s-maxage=10, stale-while-revalidate=59
/legacy/books/99      -> 404
/legacy/books/dune    -> 307   location: /legacy/books/1
[gSSP] /legacy/books/1?x=1 {"x":"1","id":"1"}

permanent: true gives a 308; the rare statusCode property is for old clients and cannot be combined with permanent. A thrown error renders pages/500.js. The s-maxage=10, stale-while-revalidate=59 header is the documented caching pattern for SSR output that is not personalised: a shared cache treats the response as fresh for 10 seconds, serves it stale for up to 59 more while it revalidates in the background, and the docs add that getStaticProps with ISR is usually a better fit. Source: getServerSideProps API reference and Caching with SSR

What goes over the wire

Two details differ from the App Router, and both are visible with curl. The initial HTML carries the props in NEXT_DATA, so everything you return in props is readable in the page source:

The props are in the document
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"title":"Neuromancer","price":"8.99"},"__N_SSP":true},"page":"/legacy/books/[id]", ...}</script>
<main><h1>Neuromancer</h1><p>8.99<!-- --> EUR</p></main>

And a client-side navigation to the page does not fetch HTML; it asks the server to run getServerSideProps again and return JSON:

The data route (BUILD_ID is the value in .next/BUILD_ID)
curl -si -H 'x-nextjs-data: 1' 'http://localhost:3000/_next/data/BUILD_ID/legacy/books/2.json?id=2'
{"pageProps":{"title":"Neuromancer","price":"8.99"},"__N_SSP":true}

The HTTP response also carried Cache-Control: public, s-maxage=10, stale-while-revalidate=59, which is what the function set. Source: getServerSideProps: behavior

App Router and Pages Router compared

A two column diagram titled One SSR request, two routers. The left column, the Pages Router, has six steps: the browser requests the page; getServerSideProps runs with req, res, query and params and returns props, a redirect or notFound; the page renders to HTML with nothing sent yet; one response carries the HTML and the __NEXT_DATA__ JSON with status and headers set by the function; the whole page hydrates; client navigation fetches a JSON data file under _next/data. The right column, the App Router, has six steps: the browser requests the page; Server Components render and await cookies, headers, searchParams and data; the first flush sends the shell, with Suspense fallbacks going first and status and headers fixed at that point; the stream continues with HTML plus RSC payload as each boundary resolves; only Client Components hydrate; client navigation fetches an RSC payload with the RSC header and content type text/x-component.
Table 7. getServerSideProps and its App Router equivalents
Concern Pages Router App Router

Where the request-time code lives

export async function getServerSideProps(context) in the page file.

In the async Server Component itself (or a function it awaits); no special export.

Data to the UI

Returned props, serialised into NEXT_DATA; the whole page hydrates.

Direct rendering; only serialisable props to Client Components cross the boundary; Server Components ship no JavaScript.

Cookies and headers

context.req.cookies, context.req.headers.

await cookies(), await headers() from next/headers.

Query string and route params

context.query, context.params.

await searchParams, await params page props.

Redirect and 404

Return { redirect } or { notFound: true }.

Call redirect(), permanentRedirect() or notFound().

Response headers

context.res.setHeader(…​).

Route Handler, headers() in next.config.ts, or proxy.ts; not from a page.

Caching

Cache-Control set by hand; getStaticProps with ISR as the alternative.

Dynamic pages answer private, no-store by default; prerender or cache the static parts ("use cache" with Cache Components).

Streaming and partial results

None per component: all of getServerSideProps finishes before the page renders.

<Suspense> boundaries stream; one slow query no longer blocks the shell.

Errors

A thrown error shows pages/500.js.

error.js boundaries per segment (see Layouts, Templates and Special Files).

Client navigation

Fetches /_next/data/BUILD_ID/path.json.

Fetches the RSC payload with the RSC: 1 header.

The Pages Router is still supported in 16.3 and is the right place for an existing application you are not migrating; for new work, the App Router is where the new features land (Cache Components, after, the streaming model, Server Components). Migration is incremental: the two routers can run in one project, and the official guide maps getServerSideProps to an async Server Component that reads Request-time APIs and fetches with cache: "no-store". The app equivalent of reading req is cookies() and headers(); of res is a Route Handler or proxy.ts.

The Books' SSR Examples, Rewritten for the App Router

The two reference books were written for the Pages Router (Next.js 12 and 13.2). Their SSR material falls into three patterns; each is restated here with current APIs rather than copied. The books are consulted references only (see the section bibliography); where they are outdated, the table says how.

Table 8. Three book-era SSR patterns and their current form
Book pattern (Pages Router) Current App Router form What changed

A page fetching an external API in getServerSideProps and returning it as props (Riva, rendering strategies).

An async Server Component that awaits the data directly, ideally under <Suspense>: const res = await fetch(url, { cache: "no-store" }) in the previous model, or a plain awaited call in Cache Components.

No props plumbing; no NEXT_DATA; the response can stream. fetch is uncached by default since 15, so cache: "no-store" is mostly explicit documentation of intent.

A catalogue listing served with Cache-Control: s-maxage, stale-while-revalidate set in getServerSideProps (Libby, ch. 3).

If the listing is the same for everyone, prerender it and revalidate (ISR, or "use cache" with cacheLife). If you must keep SSR and a CDN, set the header with the headers() config or a Route Handler.

res.setHeader has no equivalent in a page; and a hand-set public Cache-Control must never be applied to a page that reads cookies. See the verified override earlier.

Server-only logic hidden by a typeof window check (Riva, CSR versus SSR).

Keep the logic in a Server Component or a server-only module; in a Client Component use useEffect for browser-only work, next/dynamic with ssr: false only for a Client Component, or an Intl formatter with a fixed locale and time zone.

typeof window branches in render cause hydration mismatches; the server and the first client render must produce the same markup.

The corrected form of the first pattern, with the Bookshelf catalogue under the previous model and a deliberately explicit fetch:

A server-rendered page that fetches an API (previous model)
export default async function Page() {
  // the previous model: fetch is not cached by default; this makes the intent explicit
  const res = await fetch("http://localhost:3000/api/books?q=dune", { cache: "no-store" });
  const books: { title: string }[] = await res.json();
  return <p>{books.map((b) => b.title).join(", ")}</p>;
}

The route is ƒ /ssr/nostore and rendered <p>Dune</p>. In a real application call the data layer (lib/books.ts) directly instead of fetching your own Route Handler from a Server Component; a round trip to yourself adds latency and a second server render. Source: App Router migration guide: getServerSideProps

SSR Cost and Scaling

SSR moves rendering from build time to request time, so the cost scales with traffic instead of with deployments. Three effects matter.

  • Latency on the critical path. The browser waits for the server to render before it receives the first byte of HTML (time to first byte, TTFB). A slow query in an un-boundaried page delays everything. Streaming shortens this: the shell leaves as soon as it is ready.

  • CPU and memory per request. Rendering React, serialising the RSC payload and running your data layer is CPU work on a Node.js process. Throughput is bounded by cores, and a slow dependency holds a request open and ties up memory.

  • Hosting requirements. A static export needs only a file server; SSR needs a running Node.js server (or an adapter for a platform that provides one), and after() and streaming need the platform to support them.

Table 9. Levers for making SSR cheaper
Lever How it helps

Prerender what you can

A route (or the shell of one) that reads no request data costs no server work per request. With Cache Components the shell is static and only the holes render.

Cache the data, not the page

"use cache"/cacheLife (Cache Components) or fetch caching and unstable_cache (previous model) avoid repeating the same query for different users.

Push request-time access down the tree

Read cookies() or searchParams in the smallest component, under <Suspense>, so the rest is static or streams early.

Avoid waterfalls

Start independent fetches together (Promise.all) instead of awaiting one after another, otherwise latency is the sum.

Use a CDN for public SSR

Only for output identical for every visitor, with an explicit s-maxage and stale-while-revalidate; prerendering with revalidation is usually simpler.

Move non-essential work to after()

Logging and analytics stop contributing to response time.

Scale out, and keep instances stateless

Run several Node.js processes or containers behind a load balancer; do not keep per-user state in process memory; share the cache handler if you run several instances.

Make sure streaming survives the proxy

Disable buffering in nginx (X-Accel-Buffering: no) and use a load balancer that passes chunked responses; otherwise the user waits for the full render and the TTFB advantage disappears.

Upgrade

16.3 moves App Router SSR to native Node.js streams, which the team measured at up to about 22 percent more requests handled under load.

No number on this page should be read as a capacity plan. Measure your own routes (a load test against next start with realistic data and cookies) before deciding between "SSR the page", "stream the page" and "prerender the page".

What Changed Since Next.js 13 and 14

Most existing material describes the 13/14 world. These are the changes that alter how SSR code is written or behaves.

Table 10. SSR-relevant changes from 13 to 16.3
Version Change Effect on this page’s code

9.3 / 10

getServerSideProps introduced (9.3); notFound added (10).

The Pages Router model, unchanged and still supported.

13.4

The App Router became stable.

Async Server Components replace getServerSideProps.

15.0

cookies(), headers() and draftMode() are async; params and searchParams are promises; connection() stable (replaces unstable_noStore); fetch and GET Route Handlers are no longer cached by default.

await every Request-time API; "dynamic by default" for data fetching, so cache: "no-store" is mostly redundant.

15.1

after() stable.

Post-response work without a custom server.

16.0

Synchronous access to the async APIs removed; middleware became proxy.ts on Node.js; cacheComponents introduced; Turbopack the default bundler.

await is mandatory; a request-time check in proxy.ts; both caching models exist.

16.3

io() added; the App Router rendering layer uses native Node.js streams instead of Web Streams.

await io() for synchronous per-request values under Cache Components; faster SSR with no code change.

Pitfalls

Table 11. Common SSR mistakes, the symptom and the fix
Mistake Symptom Fix

const jar = cookies() without await (13/14 style)

A type error, or a runtime error: synchronous access was removed in 16.

const jar = await cookies(); the same for headers(), draftMode(), params, searchParams.

Reading request data outside <Suspense> with Cache Components

Build error blocking-prerender-dynamic (quoted above).

Move the read into a component under <Suspense>, cache the data with "use cache", or, last resort, export const instant = false.

export const dynamic = "force-dynamic" with Cache Components

Build error: segment config "dynamic" is not compatible with cacheComponents.

Remove it; the route is dynamic by default.

Setting a cookie while rendering a page

Error: Cookies can only be modified in a Server Action or Route Handler. and a 500.

Set it in a Server Function or Route Handler (or in proxy.ts).

Reading cookies() or headers() inside the after() callback of a Server Component

A runtime error.

Read the values before calling after() and close over them.

notFound() or redirect() inside a streamed <Suspense> boundary

The response is 200 with noindex (or a client-side redirect), not a real 404 or 307.

Decide before streaming starts: before the boundary, or in proxy.ts.

Branching on typeof window, Date.now() or Math.random() during render

A hydration mismatch warning; the server markup and the first client render differ.

Keep render pure; move browser-only values to useEffect or a ref; use await connection() or await io() for per-request values on the server.

public Cache-Control on a page that reads cookies

One reader’s page is served to another by a shared cache.

Keep the private default for personalised pages; set public, s-maxage only for identical-for-everyone output.

Putting a secret into props (Pages Router) or into a Client Component prop

The value is readable in NEXT_DATA or the RSC payload in the page source.

Keep secrets on the server; pass only what the browser may see, and check permissions on the server.

Awaiting independent fetches one after another

Latency is the sum of the calls; TTFB grows.

Promise.all, or start the promises early and await later; boundary each part with <Suspense>.

A proxy or load balancer that buffers the response

No streaming; the user sees nothing until the whole render has finished.

Disable buffering (X-Accel-Buffering: no) and use infrastructure that supports chunked transfer or HTTP/2 streaming.

Fetching your own Route Handler from a Server Component

An extra network hop, a second render and an extra cold path.

Call the shared function in lib/ directly.

Assuming new Date() runs per request

The timestamp is frozen at build time (previous model) or fails the build (Cache Components).

await connection() or await io() first.

Starting next start with a different cacheComponents setting than the build

500 errors with the digest DYNAMIC_SERVER_USAGE (seen while testing this page).

Build and start with the same configuration.

Where the Books Are Outdated

Book statement or practice Current approach

SSR is getServerSideProps and nothing else (both books, Next.js 12 and 13.2).

In the App Router, SSR is an async Server Component reading Request-time APIs; getServerSideProps is Pages Router only.

context.req.cookies and context.query read synchronously.

In the App Router these are await cookies() and await searchParams (async since 15, sync access removed in 16).

The whole page waits for getServerSideProps before any HTML is sent.

Suspense streams a shell first; with Cache Components the shell is prerendered.

Cache-Control for SSR set with res.setHeader.

Route Handler, headers() config or proxy.ts; prefer prerendering with revalidation for shared content.

Dynamic output marked with unstable_noStore or checked with typeof window.

connection() (15) or io() (16.3); keep render pure.

middleware.ts for request-time redirects.

proxy.ts on Node.js (16).