Client-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 side by side, because client-side rendering (CSR) is the one strategy that looks identical in both routers and works very differently under the surface. Caching matters little for code that runs in the browser, so most examples are independent of the caching model; where an example touches the server it says whether it was verified with the previous model (the default) or with Cache Components (cacheComponents: true); the examples that touch the server were built and run under both. Every example was built with next build and served with next start (Node.js 24.16, macOS, Chrome driven by Playwright for the browser checks) 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 and the browser returned.

Client-side rendering means that the component that shows the data runs in the browser: the server sends a shell, the browser downloads JavaScript, React renders, effects fetch data, and React renders again. This page explains what that costs and buys, what Next.js still does on the server for a "client" component (more than most people expect), the three ways of keeping a component out of the server render, how to fetch on the client without races and waterfalls, how SWR and TanStack Query fit in, how to build a single-page application (SPA) or a static-export SPA with Next.js, and how all of it looks in the Pages Router. It builds on Rendering Fundamentals, which compares CSR with the other strategies, and on Server and Client Components, which explains the boundary. The sibling pages on static site generation, incremental static regeneration, hydration and partial prerendering are written alongside this one; hydration in particular owns the catalogue of mismatch errors that skipping SSR avoids.

What CSR Is and When It Is Still the Right Choice

In a strict SPA the whole application is one HTML file. Every route change and every data request is handled by JavaScript in the browser, and a full-page reload is never needed (this is the definition used by the official single-page-applications guide). Next.js does not force that model: it lets you choose per component, and what people call "CSR in Next.js" is usually one of three things.

Table 1. Three things that get called CSR in Next.js
What What the server does What the browser does

A Client Component that fetches in an effect (the useEffect or SWR pattern)

Prerenders it once with its initial state (loading UI) at build time or per request, and sends that HTML.

Hydrates, then runs the effect, fetches, and renders again with the data.

A component loaded with ssr: false, use(browser()) or a mounted flag

Sends only its fallback. The component itself is not rendered on the server.

Renders it for the first time after hydration.

A strict SPA (output: 'export' with a catch-all route and ssr: false at the top)

Nothing at request time: a static host serves one HTML file.

Everything: routing, data, rendering.

CSR trades server work and first-paint speed for interactivity and simplicity. Use it where the trade is good:

Table 2. When CSR fits and when it does not
Good fit Poor fit

Highly interactive widgets: editors, charts, maps, drag and drop, dashboards whose state lives in the browser.

Public content that must be indexed: product pages, articles, anything where the largest paint is data.

Per-user screens behind a login that no crawler visits: an account page, the Bookshelf seller dashboard.

Pages where the first visit decides the conversion: the data is never in the HTML, so the content paints last.

Data that changes while the page is open: polling, focus revalidation, live prices, autocomplete.

Chains of dependent requests that the server could run next to the data in one round trip (see the waterfall below).

Browser-only APIs: localStorage, window.matchMedia, geolocation, Web Audio, canvas.

Slow devices or networks: all the rendering work and all the dependencies move to the user’s CPU and connection.

A migration from Create React App or Vite, where a working SPA must ship first and move to the server later.

Secrets: nothing in a client-rendered component is private.

The practical modern answer is rarely "CSR for the whole page". Server Components render the page shell and the data that everybody sees; a Client Component handles the interactive island, and fetches on the client only what depends on interaction. The React-level mechanics are covered in this site’s React section (Data Fetching and Effects and Lifecycle); this page is about what Next.js adds.

Client Components on First Load and on Navigation

A Client Component is not "client only". The 'use client' directive marks a boundary in the module graph; code on the client side of it is shipped to the browser, but it is still rendered on the server for the first load. What differs between the first load and later navigations is where the first render happens.

sequenceDiagram autonumber actor U as User participant B as Browser participant S as Next.js server Note over U,S: First load of a page with a Client Component U->>B: open /csr/first B->>S: GET /csr/first S-->>B: HTML: the component rendered once with its initial state, plus the RSC payload B-->>U: preview paints, effects have not run B->>S: load the client chunks Note over B: Hydration: render again in the browser,
attach handlers, run effects Note over U,S: Navigation from another page of the same app U->>B: click a Link to /csr/first B->>S: RSC payload only (prefetched earlier, no HTML) S-->>B: payload with the props of the Client Component Note over B: First render happens in the browser, no server HTML,
no hydration, then effects run B-->>U: page updates without a reload

The scratch app has a component that logs where it renders and a link to it.

A Client Component that reports where it renders
// components/RenderProbe.tsx
"use client";

import { useEffect, useState } from "react";

export function RenderProbe({ label }: { label: string }) {
  const where = typeof window === "undefined" ? "server" : "browser";
  console.log(`[probe] ${label} rendered on the ${where}`);
  const [effectRan, setEffectRan] = useState(false);
  useEffect(() => setEffectRan(true), []);
  return <p data-testid="probe">{label}: effect ran = {String(effectRan)}</p>;
}

The results for the route /csr/first (a Server Component page that renders <RenderProbe label="first" />) and a page /csr/nav with <Link href="/csr/first">:

Table 3. What the server, the network and the browser showed (previous model and Cache Components alike)
Moment Evidence Meaning

next build

[probe] first rendered on the server printed under "Generating static pages"

The Client Component is prerendered at build time; the route is ○ (static) and the HTML contains effect ran = false.

Direct load of /csr/first

One document request; the HTML contains first: effect ran = false; the browser logs rendered on the browser twice (hydration render, then the re-render after the effect set state); then the text reads effect ran = true.

Server HTML, hydration, effect: the full path.

Click on the link in /csr/nav

No document request. The payload had been fetched before the click as fetch /csr/first?_rsc=…​ with the RSC: 1 and Next-Router-Prefetch: 1 request headers; the browser logs the component rendering on the browser.

The first render is in the browser, from the payload; there is no server HTML and no hydration.

So "client-side rendering" in an App Router app is partly a matter of which request you are looking at: a hard load renders the Client Component on the server and in the browser, a soft navigation renders it only in the browser. The details of the router cache and prefetching are in Linking & Navigation; the server-side half of the story is in Server and Client Components.

Skipping SSR: Keeping a Component Out of the Server Render

Some components cannot run on the server: they read localStorage while rendering, wrap a library that touches window at import time, or draw into a canvas. Rendering them on the server either throws (a ReferenceError during prerender) or produces output that the browser then disagrees with (a hydration mismatch). There are three techniques, and they differ in what ends up in the HTML.

The component the three techniques wrap
// components/ShelfSummary.tsx
"use client";

import { useState } from "react";

// Reads localStorage while rendering: only safe where there is no server render.
export function ShelfSummary() {
  const [ids] = useState(() => (localStorage.getItem("shelf") ?? "").split(",").filter(Boolean));
  return <p data-testid="summary">{ids.length} saved books (from localStorage)</p>;
}

next/dynamic with ssr: false

next/dynamic is a composite of React.lazy() and <Suspense>, and behaves the same way in the app and pages directories. With ssr: false the server does not render the component; it renders the loading component instead and the browser fetches the component’s chunk and renders it after hydration. The option only works in a Client Component.

next/dynamic with ssr: false, in a Client Component
"use client";

import dynamic from "next/dynamic";

const DynamicSummary = dynamic(() => import("./ShelfSummary").then((m) => m.ShelfSummary), {
  ssr: false,
  loading: () => <p data-testid="summary">Loading summary…</p>,
});

export function DynamicSkip() {
  return <DynamicSummary />;
}

The HTML that curl received from the route that renders it contained the fallback and a marker, not the summary:

<template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING"></template><p data-testid="summary">Loading summary…</p>

After hydration, with localStorage.shelf = "1,3", the paragraph read 2 saved books (from localStorage) and the browser console was empty. The same call in a Server Component is rejected at build time. The build failed with:

Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.

This restriction was introduced in Next.js 15 (listed as a breaking change in the 15 release notes) and holds in 16.3.8. A dynamic import in a Server Component is still fine without ssr: false: only the Client Components below it are lazy-loaded, and the lazy-loading guide notes that automatic code splitting is not currently supported when a Server Component imports a Client Component dynamically.

use(browser()) (React 19.3)

React 19.3 (2026-09-09) added a first-class way to say "this component is browser-only": call browser() from react-dom inside use(). During server rendering, use(browser()) stops rendering the component and leaves the closest <Suspense> fallback in its place; in the browser it returns undefined and the component renders normally. It replaces typeof window checks, a mounted flag, and the framework option for the cases it covers.

use(browser()) inside a Suspense boundary
"use client";

import { Suspense, use } from "react";
import { browser } from "react-dom";
import { ShelfSummary } from "./ShelfSummary";

function BrowserOnlySummary() {
  use(browser("The summary reads localStorage.")); // optional reason string
  return <ShelfSummary />;
}

export function BrowserSkip() {
  return (
    <Suspense fallback={<p data-testid="summary">Loading summary…</p>}>
      <BrowserOnlySummary />
    </Suspense>
  );
}

Verified on 16.3.8: the HTML contained <template data-dgst=""></template><p data-testid="summary">Loading summary…</p> (the fallback, no localStorage read), and after hydration the summary read 2 saved books, with no console errors. The React documentation lists three caveats, and the first was reproduced:

  • It must be inside a <Suspense> boundary during server rendering. Without one the server render fails; in the scratch app next build stopped with "The server render could not complete because client rendering was requested outside a Suspense boundary" (React error 603).

  • It must be called from a Client Component, not a Server Component.

  • browser() by itself does nothing: pass its result to use, and never throw it.

Two-pass rendering with an effect

The oldest technique needs no API: render the same output on the server and in the first client render (a fallback), then switch after mount. Effects do not run on the server, so the second pass is client-only.

A mounted flag
"use client";

import { useEffect, useState } from "react";
import { ShelfSummary } from "./ShelfSummary";

export function TwoPassSkip() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return <p data-testid="summary">Loading summary…</p>;
  return <ShelfSummary />;
}

The HTML again contained only the fallback, and the summary appeared after hydration. Because the first client render equals the server render, there is no hydration mismatch; the cost is one extra render and a flash of the fallback.

Choosing between them

Table 4. The three techniques compared (all verified on 16.3.8)
next/dynamic with ssr: false use(browser()) Mounted flag

Code splitting

Yes: the component’s chunk is fetched on demand.

No by itself (combine with React.lazy or dynamic if the chunk is large).

No by itself.

What the HTML contains

The loading component and a BAILOUT_TO_CLIENT_SIDE_RENDERING marker.

The <Suspense> fallback.

Whatever the !mounted branch renders.

Needs a Suspense boundary

No (it has loading).

Yes, or the build fails.

No.

Where it may be called

Client Components only.

Client Components only.

Client Components only.

Works in the Pages Router

Yes.

React 19.3 only (the Pages Router still supports React 18).

Yes.

Best for

A big or browser-only library you want out of the main bundle.

A small browser-only branch (localStorage, time zone) inside an otherwise server-rendered tree.

Any version of React; a quick fix.

flowchart TD A["A Client Component breaks on the server
or differs between server and browser"] --> B{"Can it simply not read
browser state while rendering?"} B -->|"yes: move the read into an effect"| C["Plain Client Component,
the effect sets state"] B -->|"no"| D{"Large or window-dependent
library, worth a separate chunk?"} D -->|"yes"| E["next/dynamic with ssr: false
in a Client Component"] D -->|"no"| F{"React 19.3 available?"} F -->|"yes"| G["use(browser()) inside Suspense"] F -->|"no"| H["Mounted flag: two-pass rendering"]

Two related notes. suppressHydrationWarning hides a mismatch on one element but does not fix it, and the inline-script pattern for avoiding a flash of the wrong theme before hydration has its own guide; both belong to the hydration page of this section. And skipping SSR removes the component from the HTML for crawlers and for users without JavaScript, so keep it for content that is not meant to be indexed.

Fetching Data on the Client Correctly

The textbook pattern is a useEffect that calls fetch and sets state. It is also the pattern most often written wrongly. A tutorial-style version, with no cleanup and no status check:

The naive effect (do not copy)
// components/NaiveSearch.tsx
"use client";

import { useEffect, useState } from "react";

type Book = { id: string; title: string };

export function NaiveSearch() {
  const [q, setQ] = useState("");
  const [results, setResults] = useState<Book[]>([]);

  useEffect(() => {
    fetch(`/api/csr/search?q=${q}`)
      .then((r) => r.json())
      .then((data) => setResults(data));
  }, [q]);

  return (
    <div>
      <input aria-label="Search" value={q} onChange={(e) => setQ(e.target.value)} />
      <ul data-testid="results">
        {results.map((b) => (
          <li key={b.id}>{b.title}</li>
        ))}
      </ul>
    </div>
  );
}

It has four defects: the query is not URL-encoded; an HTTP error is parsed as if it were data; a network error is an unhandled rejection; and, worst, responses can arrive out of order, so an old answer overwrites a newer one. The race is easy to reproduce. The scratch Route Handler answers short queries more slowly than long ones (s after 400 ms, sol after 50 ms), which is what a loaded server or a mobile network does at random:

A Route Handler with simulated latency, only to expose the race
// app/api/csr/search/route.ts
import { listBooks } from "@/lib/books";

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

export async function GET(request: Request) {
  const q = new URL(request.url).searchParams.get("q") ?? "";
  await sleep(Math.max(50, 700 - q.length * 300)); // "s" -> 400 ms, "sol" -> 50 ms
  return Response.json(await listBooks(q));
}

Source: Route Handlers

Typing s, o, l quickly in Chrome (Playwright, 30 ms between keys) left these results after 1.2 seconds:

Table 5. Same input sol, same server, two components
Component Results shown Why

NaiveSearch

Neuromancer, Solaris

The slow answer for s arrived last and replaced the answer for sol. The list does not match the input.

BookSearch (below)

Solaris by Stanislaw Lem

The effect cleanup aborted the earlier requests, so only the latest query can update state.

The corrected component starts a new AbortController per effect run, aborts it in the cleanup (which React runs before the next effect and on unmount), treats an AbortError as "superseded", checks res.ok, encodes the query, and models the states explicitly.

The corrected effect: abort, status check, explicit states
// components/BookSearch.tsx
"use client";

import { useEffect, useState } from "react";

type Book = { id: string; title: string; author: string };
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "done"; books: Book[] };

export function BookSearch() {
  const [q, setQ] = useState("");
  const [state, setState] = useState<State>({ status: "idle" });

  useEffect(() => {
    if (q.trim() === "") {
      setState({ status: "idle" });
      return;
    }
    const controller = new AbortController(); // one controller per effect run
    setState({ status: "loading" });
    fetch(`/api/csr/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json() as Promise<Book[]>;
      })
      .then((books) => setState({ status: "done", books }))
      .catch((err) => {
        if (err.name === "AbortError") return; // superseded by a newer query: ignore
        setState({ status: "error", message: err.message });
      });
    return () => controller.abort(); // runs before the next effect and on unmount
  }, [q]);

  return (
    <div>
      <input aria-label="Search" value={q} onChange={(e) => setQ(e.target.value)} />
      {state.status === "loading" && <p>Searching…</p>}
      {state.status === "error" && <p role="alert">{state.message}</p>}
      <ul data-testid="results">
        {state.status === "done" &&
          state.books.map((b) => (
            <li key={b.id}>
              {b.title} by {b.author}
            </li>
          ))}
      </ul>
    </div>
  );
}

In development, React’s Strict Mode mounts, unmounts and mounts a component once to expose missing cleanup, so you will see the first request cancelled and a second one made; that is the cleanup working, not a bug. Even corrected, a hand-written effect has no cache, no deduplication, no retry and no revalidation, which is why the official guides recommend a library for anything beyond a single read (see below). For a read that is needed only once, the first choice in the Next.js client-data-fetching guide is not a library at all but a promise started on the server and unwrapped with use().

Waterfalls: Client Fetching Versus Server-Fetched Data

A network waterfall is a chain of requests where each cannot start until the previous one has finished and rendered. Client fetching makes them easy to create, because a component only starts fetching after it has mounted, and it only mounts after its parent has rendered its own data. The scratch app has a shelf that needs three dependent reads: the session (to get the user id), the shelf (the book ids of that user), and the books (by id).

Three dependent requests, each started by an effect after the previous result rendered
// components/ShelfWaterfall.tsx (the three effects, abbreviated)
"use client";

import { useEffect, useState } from "react";

async function getJson<T>(url: string, signal: AbortSignal): Promise<T> {
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`${url}: ${res.status}`);
  return res.json();
}

export function ShelfWaterfall() {
  const [userId, setUserId] = useState<string | null>(null);
  const [ids, setIds] = useState<string[] | null>(null);
  const [books, setBooks] = useState<{ id: string; title: string }[] | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    getJson<{ userId: string }>("/api/csr/session", controller.signal)
      .then((s) => setUserId(s.userId))
      .catch(() => {}); // abort and error handling as in BookSearch
    return () => controller.abort();
  }, []);

  useEffect(() => {
    if (!userId) return; // waits for the first request
    const controller = new AbortController();
    getJson<string[]>(`/api/csr/shelf?user=${userId}`, controller.signal)
      .then(setIds)
      .catch(() => {});
    return () => controller.abort();
  }, [userId]);

  useEffect(() => {
    if (!ids) return; // waits for the second request
    const controller = new AbortController();
    getJson<{ id: string; title: string }[]>(`/api/csr/books?ids=${ids.join(",")}`, controller.signal)
      .then(setBooks)
      .catch(() => {});
    return () => controller.abort();
  }, [ids]);

  if (!books) return <p data-testid="shelf">Loading shelf…</p>;
  return (
    <ul data-testid="shelf">
      {books.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ul>
  );
}

The server alternatives do the same three dependent reads. In the first, a Server Component awaits them next to the data; in the second, the page starts one promise and passes it to a Client Component that unwraps it with use(), so the HTML shell is sent immediately and the data streams in.

The same shelf, fetched on the server (an async Server Component under Suspense)
// app/csr/server/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getSession, getShelfIds, getBooksByIds } from "@/lib/shelf-data";

async function Shelf() {
  await connection(); // per request: the shelf belongs to the reader
  // Same three dependent reads, but next to the data: three short hops, one response.
  const session = await getSession(30);
  const ids = await getShelfIds(session.userId, 30);
  const books = await getBooksByIds(ids, 30);
  return (
    <ul data-testid="shelf">
      {books.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ul>
  );
}

export default function Page() {
  return (
    <main>
      <h1>My shelf (server-fetched)</h1>
      <Suspense fallback={<p data-testid="shelf">Loading shelf…</p>}>
        <Shelf />
      </Suspense>
    </main>
  );
}
The same shelf as a promise passed to a Client Component
// app/csr/promise/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { getBooksByIds } from "@/lib/shelf-data";
import { ShelfFromPromise } from "@/components/ShelfFromPromise";

async function Shelf() {
  await connection(); // per request
  const booksPromise = getBooksByIds(["1", "3"], 30); // started on the server, not awaited
  return <ShelfFromPromise booksPromise={booksPromise} />;
}

export default function Page() {
  return (
    <main>
      <h1>My shelf (promise to a Client Component)</h1>
      <Suspense fallback={<p data-testid="shelf">Loading shelf…</p>}>
        <Shelf />
      </Suspense>
    </main>
  );
}

// components/ShelfFromPromise.tsx
// "use client";
// import { use } from "react";
// export function ShelfFromPromise({ booksPromise }: { booksPromise: Promise<{ id: string; title: string }[]> }) {
//   const books = use(booksPromise); // suspends until the server resolves the promise
//   return <ul data-testid="shelf">{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul>;
// }

The numbers below are from next start in Chrome, with the CSR endpoints answering after a simulated 300 ms (a slow mobile round trip) and the server-side reads after 30 ms (co-located data). The shape matters more than the absolute values: the client chain is latency x number of dependent requests, plus the time before the JavaScript can start it.

Table 6. Time until the books are visible (previous model; Cache Components gave 1039, 133 and 59 ms)
Strategy Books visible Why

Client effects (/csr/waterfall)

about 1006 ms

HTML and JavaScript first (the first request started at about 80 ms), then three requests of 300 ms in a row (started at 78, 386 and 692 ms).

await in a Server Component (/csr/server)

about 118 ms

The shell was sent first; three 30 ms reads ran next to the data; the result streamed in one response. No browser request was made.

Promise and use() (/csr/promise)

about 58 ms

One read, started before render; the shell and the data arrived in the same streamed response.

A timeline with three lanes. Client fetching: the document, scripts and hydration take about 80 ms, then three dependent browser requests of 300 ms each (session, shelf, books) run one after another and the books are visible at about 1006 ms. Server fetching: the shell is sent at about 45 ms, three 30 ms server reads follow and the books are visible at about 118 ms. Server promise: the shell is sent at about 47 ms and the books are visible at about 58 ms.

How to avoid client waterfalls when you must fetch on the client:

  • Start earlier. Start the request in a Server Component and pass the promise down (above), or prefetch with a library (queryClient.prefetchQuery, SWR preload) before the component that needs it renders.

  • Fetch in parallel. Requests that do not depend on each other belong in sibling components or one Promise.all. The official guides note that independent Suspense reads in sibling components start in parallel, while several Suspense reads in one component run sequentially.

  • Collapse dependent requests. Three dependent calls behind one Route Handler (a backend for frontend) cost one round trip from the browser instead of three.

  • Do not move data fetching into the browser just to avoid await. If the server can read it, read it there.

SWR and TanStack Query, Optionally Seeded from the Server

When client components need a shared browser cache, focus revalidation, polling, request deduplication or optimistic updates, use a data-fetching library. The Next.js guide names SWR, TanStack Query and Apollo Client and frames three patterns:

Table 7. Client fetching patterns (from the official guide)
Pattern SWR TanStack Query When data is available

Inline loading states

useSWR

useQuery

Browser request after hydration.

Suspense loading states

useSWR with suspense: true

useSuspenseQuery

Browser request after hydration.

Provided by the server

<SWRConfig fallback>

<HydrationBoundary>

Initial render, or streamed from the server.

Client-only: autocomplete with SWR

For a result that depends on a keystroke there is nothing to seed, so the request is made in the browser. SWR’s null key means "do not fetch yet".

SWR, inline loading state (swr 2.5.1)
// components/SwrSearch.tsx
"use client";

import { useState } from "react";
import useSWR from "swr";

type Book = { id: string; title: string };

async function fetcher(url: string): Promise<Book[]> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch books");
  return res.json();
}

export function SwrSearch() {
  const [q, setQ] = useState("");
  const { data = [], error, isLoading } = useSWR(
    q ? `/api/csr/search?q=${encodeURIComponent(q)}` : null, // null key: no request yet
    fetcher,
  );
  return (
    <div>
      <input aria-label="Search" value={q} onChange={(e) => setQ(e.target.value)} />
      {error && <p role="alert">Failed to load books.</p>}
      {isLoading && <p>Searching…</p>}
      <ul data-testid="results">
        {data.map((b) => (
          <li key={b.id}>{b.title}</li>
        ))}
      </ul>
    </div>
  );
}

Typing sol in the browser produced Solaris. SWR stores each result under its request key, so a late answer for an old key cannot replace the current one, and it deduplicates identical requests; the hand-written effect had to do both by hand.

Seeded from the server: SWR fallback

When the first render needs the data, a Server Component starts the read and gives the Promise to <SWRConfig> as fallback; React passes the Promise through the RSC payload, and the component reading the key suspends until it resolves. SWR 2.3.0 and React 19 are required. The key must be defined once and shared.

The shared key
// app/csr/swr/[id]/book-cache.ts
export const bookCache = {
  key: (id: string) => `/api/csr/book/${id}`,
};
The Server Component page: fallback promise, not awaited
// app/csr/swr/[id]/page.tsx
import { Suspense } from "react";
import { SWRConfig } from "swr";
import { getBook } from "@/lib/books";
import { bookCache } from "./book-cache";
import { BookView } from "./book-view";

export default function Page({ params }: PageProps<"/csr/swr/[id]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {params.then(({ id }) => (
        <BookData id={id} />
      ))}
    </Suspense>
  );
}

function BookData({ id }: { id: string }) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // Not awaited: only the component that reads this key suspends.
          [bookCache.key(id)]: getBook(id),
        },
      }}
    >
      <BookView id={id} />
    </SWRConfig>
  );
}
The Client Component reads the same key
// app/csr/swr/[id]/book-view.tsx
"use client";

import useSWR from "swr";
import { bookCache } from "./book-cache";

type Book = { id: string; title: string; author: string };

async function fetcher(url: string): Promise<Book> {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch book");
  return res.json();
}

export function BookView({ id }: { id: string }) {
  const { data } = useSWR(bookCache.key(id), fetcher, { suspense: true });
  return (
    <h1 data-testid="title">
      {data.title} by {data.author}
    </h1>
  );
}

curl returned the title in the HTML (Neuromancer by William Gibson); the browser then made one request to /api/csr/book/2, because SWR treats fallback data as stale and revalidates on mount. The setting revalidateIfStale: false skips that request. If the two keys drift apart, SWR ignores the fallback and fetches on the client, so keep one definition.

Seeded from the server: TanStack Query HydrationBoundary

TanStack Query needs a provider that creates one query client per server render and one in the browser:

The provider
// app/csr/tanstack/providers.tsx
"use client";

import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

let browserQueryClient: QueryClient | undefined;

function getQueryClient() {
  // New client per server render (requests stay isolated); one client in the browser.
  if (typeof window === "undefined") return new QueryClient();
  browserQueryClient ??= new QueryClient();
  return browserQueryClient;
}

export function Providers({ children }: { children: ReactNode }) {
  return <QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>;
}
Shared query options
// app/csr/tanstack/[id]/book-cache.ts
import { queryOptions } from "@tanstack/react-query";

export type Book = { id: string; title: string; author: string };

export const bookCache = {
  key: (id: string) => ["book", id] as const,
  options: (id: string) =>
    queryOptions({
      queryKey: bookCache.key(id),
      queryFn: async (): Promise<Book> => {
        const res = await fetch(`/api/csr/book/${id}`);
        if (!res.ok) throw new Error("Failed to fetch book");
        return res.json();
      },
      staleTime: 30_000,
    }),
};
The Server Component starts the query without awaiting it
// app/csr/tanstack/[id]/page.tsx
import { Suspense } from "react";
import {
  defaultShouldDehydrateQuery,
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";
import { getBook } from "@/lib/books";
import { bookCache } from "./book-cache";
import { BookView } from "./book-view";

export default function Page({ params }: PageProps<"/csr/tanstack/[id]">) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      {params.then(({ id }) => (
        <BookData id={id} />
      ))}
    </Suspense>
  );
}

function BookData({ id }: { id: string }) {
  const queryClient = new QueryClient();
  // Not awaited, so rendering is not blocked; the server overrides queryFn (a relative URL only works in the browser).
  void queryClient.prefetchQuery({
    ...bookCache.options(id),
    queryFn: async () => {
      const book = await getBook(id);
      if (!book) throw new Error("Book not found");
      return book;
    },
  });
  return (
    <HydrationBoundary
      state={dehydrate(queryClient, {
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) || query.state.status === "pending",
      })}
    >
      <BookView id={id} />
    </HydrationBoundary>
  );
}
The Client Component
// app/csr/tanstack/[id]/book-view.tsx
"use client";

import { useSuspenseQuery } from "@tanstack/react-query";
import { bookCache } from "./book-cache";

export function BookView({ id }: { id: string }) {
  const { data } = useSuspenseQuery(bookCache.options(id));
  return (
    <h1 data-testid="title">
      {data.title} by {data.author}
    </h1>
  );
}

With @tanstack/react-query 5.104.1 (the guide requires 5.40.0 or later to dehydrate pending queries), curl returned Solaris by Stanislaw Lem in the HTML, and the browser made no request after load, because staleTime is 30 seconds. That is the practical difference between the two libraries: SWR revalidates a fallback on mount, TanStack Query honours staleTime. Both stay in control after hydration (focus, reconnect and mutations).

Table 8. SWR and TanStack Query seeded from the server, compared (both verified)
SWR TanStack Query

Server hands over

A Promise in <SWRConfig fallback>, keyed by the request key.

A dehydrated cache in <HydrationBoundary state>, keyed by queryKey.

Freshness of seeded data

Treated as stale: revalidates on mount unless revalidateIfStale: false.

Controlled by staleTime (30 s here: no request after load).

Per-request isolation

Not needed on the server.

Required: a new QueryClient per server render, or data leaks between users.

Key mismatch

Fallback ignored, client fetches.

Query is not found, client fetches.

Mutations

mutate with optimisticData, rollbackOnError.

useMutation and queryClient.invalidateQueries.

The Cache Components model adds a third cache to this picture ('use cache' and cacheTag for the server read behind the fallback, the router cache for the payload, the library’s own cache in the browser). The three layers keep independent freshness policies and must be invalidated together after a mutation (updateTag in a Server Action for read-your-own-writes, revalidateTag(tag, 'max') for stale-while-revalidate); the caching pages of this section cover the server side. Both seeded pages built and returned the data in the HTML with cacheComponents on and off.

Worked Example: the Bookshelf "Find on the Shelf" Page

The Bookshelf catalogue page is a Server Component, so its list is in the HTML for crawlers. On top of it sits an interactive search that is deliberately client-rendered: it depends on every keystroke, it is not worth indexing, and the user expects results as they type. The page is a thin Server Component shell and the search is one Client Component.

Server Component shell and a Client Component island
// app/csr/search/page.tsx
import { BookSearch } from "@/components/BookSearch"; // the corrected component above

export default function Page() {
  return (
    <main>
      <h1>Search the Bookshelf</h1>
      <BookSearch />
    </main>
  );
}

Build and run it:

$ npx next build            # previous model
$ npx next start -p 3501
$ curl -s localhost:3501/csr/search | grep -o '<h1>[^<]*</h1>\|data-testid="results">[^<]*'
<h1>Search the Bookshelf</h1>
data-testid="results">

The HTML contains the heading and an empty results list: the results are client-rendered. In Chrome, typing sol showed Solaris by Stanislaw Lem, and a quick s-o-l sequence ended with the same list, not the stale one from s. The route table lists /csr/search as ○ (static) and the Route Handler as ƒ:

next build route table (previous model, trimmed to this page’s routes)
Route (app)
├ ƒ /api/csr/search
├ ○ /csr/search
├ ○ /csr/waterfall
├ ƒ /csr/server
├ ƒ /csr/promise
├ ƒ /csr/skip
├ ○ /csr/spa
├ ○ /csr/shallow
├ ƒ /csr/swr/[id]
└ ƒ /csr/tanstack/[id]

Route (pages)
─ ○ /csr-legacy/shelf

ƒ Proxy (Middleware)

With cacheComponents: true the same build succeeded and marked the Suspense-streamed pages ◐ (Partial Prerender). To get there, two pages had to change: awaiting searchParams (the skip page) and connection() (the server and promise pages) must sit inside a <Suspense> boundary, otherwise the build fails with blocking-prerender-dynamic. The examples above already show the corrected form. Note also that next start must be given the same configuration as next build: starting the Cache Components build without the environment switch that enabled it returned HTTP 500 with DYNAMIC_SERVER_USAGE for the Suspense-streamed routes in this scratch app.

SPA Mode

The official single-page-applications guide shows four patterns that make a Next.js app behave like an SPA while keeping the server available. The first two are the ones that matter for client-side rendering.

Context-held promises with use()

Start a request in a Server Component high in the tree without awaiting it, pass the Promise to a context provider, and unwrap it with use() in any Client Component. The server starts the work early, the shell streams immediately, and no client request waterfall forms. The provider and the consumer:

A layout that starts the request and a provider that carries the promise
// app/csr/spa/layout.tsx
import { getSession } from "@/lib/shelf-data";
import { SessionProvider } from "./session-provider";

export default function Layout({ children }: LayoutProps<"/csr/spa">) {
  const sessionPromise = getSession(30); // do NOT await
  return <SessionProvider sessionPromise={sessionPromise}>{children}</SessionProvider>;
}
// app/csr/spa/session-provider.tsx
"use client";

import { createContext, use, useContext } from "react";

type Session = { userId: string; name: string };

const SessionContext = createContext<Promise<Session> | null>(null);

export function useSessionPromise() {
  const p = useContext(SessionContext);
  if (!p) throw new Error("useSessionPromise must be used within a SessionProvider");
  return p;
}

export function SessionProvider({
  children,
  sessionPromise,
}: {
  children: React.ReactNode;
  sessionPromise: Promise<Session>;
}) {
  return <SessionContext.Provider value={sessionPromise}>{children}</SessionContext.Provider>;
}

export function Greeting() {
  const session = use(useSessionPromise());
  return <p data-testid="greeting">Hello, {session.name}</p>;
}
// app/csr/spa/page.tsx
import { Suspense } from "react";
import { Greeting } from "./session-provider";

export default function Page() {
  return (
    <main>
      <h1>SPA shell</h1>
      <Suspense fallback={<p data-testid="greeting">Loading…</p>}>
        <Greeting />
      </Suspense>
    </main>
  );
}

curl returned Hello, Ada already in the streamed HTML (the data was resolved while the response was open), and the browser made no data request. Two cautions from the guide: refetching a promise set high in the tree re-runs the Server Component that set it, so place the provider on the subtree that needs the data rather than in the root layout; and wrap a shared function in React’s cache if several components read it in one request.

Shallow routing with history.pushState

Migrating SPAs often have code that updates the URL without navigating (sort order, filters, tabs). Next.js integrates the native window.history.pushState and replaceState into the router, so usePathname and useSearchParams stay in sync, and no server round trip happens.

Sorting the shelf without a navigation
// components/SortShelf.tsx
"use client";

import { useSearchParams } from "next/navigation";

const titles = ["Dune", "Neuromancer", "Solaris"];

export function SortShelf() {
  const searchParams = useSearchParams();
  const sort = searchParams?.get("sort") === "desc" ? "desc" : "asc";
  const sorted = sort === "asc" ? titles : [...titles].reverse();

  function updateSorting(order: "asc" | "desc") {
    const params = new URLSearchParams(searchParams?.toString());
    params.set("sort", order);
    // Shallow routing: URL changes, no navigation request, hooks stay in sync
    window.history.pushState(null, "", `?${params.toString()}`);
  }

  return (
    <div>
      <button onClick={() => updateSorting("asc")}>Sort A-Z</button>
      <button onClick={() => updateSorting("desc")}>Sort Z-A</button>
      <ol data-testid="list">
        {sorted.map((t) => (
          <li key={t}>{t}</li>
        ))}
      </ol>
      <p data-testid="sort">sort={sort}</p>
    </div>
  );
}
// app/csr/shallow/page.tsx
import { Suspense } from "react";
import { SortShelf } from "@/components/SortShelf";

export default function Page() {
  return (
    <main>
      <h1>Shallow routing</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <SortShelf />
      </Suspense>
    </main>
  );
}

In Chrome, clicking Sort Z-A changed the URL to /csr/shallow?sort=desc, the sort readout to sort=desc and the list to Solaris, Neuromancer, Dune, and the only request after the click was the favicon: no document, no payload. Two details are easy to trip on, and both were reproduced:

  • useSearchParams must be under a <Suspense> boundary on a prerendered route. Without it the previous model failed with "useSearchParams() should be wrapped in a suspense boundary" (missing-suspense-with-csr-bailout), and Cache Components failed with "Next.js encountered URL data useSearchParams() in a Client Component outside of <Suspense>`" (`blocking-prerender-client-hook, part of the 16.3 Instant Navigations work).

  • If the app also has a pages/ directory, useSearchParams returns ReadonlyURLSearchParams | null (the null is a migration compatibility value), and TypeScript rejects searchParams.get(…​) until you write searchParams?.get(…​). The scratch app mixes both routers, so the example above uses the optional chaining that is valid in either case.

The other two SPA patterns in the guide are mutations with Server Actions (call the action inside useTransition, add useOptimistic and useActionState for instant-feeling updates) and the library-based coordination shown earlier.

A static-export SPA

A strict SPA that any static host can serve: output: 'export', one catch-all route prerendered for /, and the whole application behind ssr: false. This is the shape the Vite and Create React App migration guides recommend as a first step, because it keeps the existing client router.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export", // `next build` writes the static site to out/
};

export default nextConfig;
The entry point: one prerendered route and a client-only app
// app/[[...slug]]/page.tsx
import { ClientOnly } from "./client";

export function generateStaticParams() {
  return [{ slug: [""] }]; // one prerendered entry point: the empty slug, "/"
}

export default function Page() {
  return <ClientOnly />;
}

// app/[[...slug]]/client.tsx
// "use client";
// import dynamic from "next/dynamic";
// const App = dynamic(() => import("../../src/App"), { ssr: false, loading: () => <p>Loading the Bookshelf…</p> });
// export function ClientOnly() { return <App />; }
The app: a tiny client router and a static JSON file as the "API"
// src/App.tsx
import { useEffect, useState } from "react";

type Book = { id: string; title: string; author: string };

// A tiny client-side router: the path is state, links call history.pushState.
function usePath() {
  const [path, setPath] = useState(() => window.location.pathname);
  useEffect(() => {
    const onPop = () => setPath(window.location.pathname);
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);
  function navigate(to: string) {
    window.history.pushState(null, "", to);
    setPath(to);
  }
  return { path, navigate };
}

function useBooks() {
  const [books, setBooks] = useState<Book[] | null>(null);
  useEffect(() => {
    const controller = new AbortController();
    fetch("/books.json", { signal: controller.signal }) // a static file from public/
      .then((r) => r.json())
      .then(setBooks)
      .catch((e) => e.name !== "AbortError" && console.error(e));
    return () => controller.abort();
  }, []);
  return books;
}

export default function App() {
  const { path, navigate } = usePath();
  const books = useBooks();
  if (!books) return <p>Loading books…</p>;

  const match = path.match(/^\/books\/(\w+)\/?$/);
  if (match) {
    const book = books.find((b) => b.id === match[1]);
    return (
      <main>
        <a href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>All books</a>
        <h1 data-testid="view">{book ? `${book.title} by ${book.author}` : "Not found"}</h1>
      </main>
    );
  }
  return (
    <main>
      <h1 data-testid="view">Bookshelf</h1>
      <ul>
        {books.map((b) => (
          <li key={b.id}>
            <a href={`/books/${b.id}`} onClick={(e) => { e.preventDefault(); navigate(`/books/${b.id}`); }}>
              {b.title}
            </a>
          </li>
        ))}
      </ul>
    </main>
  );
}

next build printed one route, /[[…​slug]] with ● for /, and wrote out/index.html, out/404.html and the payload files. The prerendered <div id="root"> contains only the BAILOUT_TO_CLIENT_SIDE_RENDERING marker and the Loading the Bookshelf… text. Served with a static host that returns index.html for unknown paths (a small Node script standing in for the "404 to /index.html with status 200" rule that S3 and CloudFront, Nginx try_files or Netlify redirects provide), the browser results were:

Table 9. Static-export SPA, checked in Chrome
Step Result

Open /

The heading Bookshelf; the books were fetched from /books.json.

Click Neuromancer

URL /books/2, heading Neuromancer by William Gibson, and no network request at all.

Back button

URL /, heading Bookshelf (the popstate listener).

Open /books/3 directly

The host served index.html; the app read the path and showed Solaris by Stanislaw Lem; no console errors. The HTML of that response did not contain the title: it contained the loading text.

The limits of this model are the limits of a static export: no Server Actions, cookies, rewrites, redirects, headers, Proxy, Draft Mode, ISR or intercepting routes, no default image optimizer, and dynamic routes need generateStaticParams (see the unsupported-features list in the static exports guide, and Redirects, Rewrites & Proxy for what you lose with Proxy). Deep links depend on a host-level rewrite to index.html, because the build only generates /. The full static-export story, including hosting on S3 and CloudFront, belongs to the static site generation page of this section.

Pages Router Comparison

In the Pages Router every page component is a Client Component in the App Router sense: it is rendered on the server (or at build time) and hydrated, and client-side rendering means not defining getServerSideProps or getStaticProps and fetching in an effect instead. The official page names two ways: a useEffect in the page, or a data-fetching library (recommended).

A client-rendered Pages Router page (verified: ○ in the Route (pages) table)
// pages/csr-legacy/shelf.tsx
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";

type Book = { id: string; title: string; author: string };

const SummaryBox = dynamic(() => import("@/components/ShelfSummary").then((m) => m.ShelfSummary), {
  ssr: false,
  loading: () => <p data-testid="summary">Loading summary…</p>,
});

export default function LegacyShelf() {
  const [books, setBooks] = useState<Book[] | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    fetch("/api/books", { signal: controller.signal })
      .then((r) => r.json())
      .then(setBooks)
      .catch((e) => e.name !== "AbortError" && console.error(e));
    return () => controller.abort();
  }, []);

  return (
    <main>
      <h1>Legacy shelf (Pages Router, client-side)</h1>
      <SummaryBox />
      {books ? <ul data-testid="shelf">{books.map((b) => <li key={b.id}>{b.title}</li>)}</ul> : <p data-testid="shelf">Loading…</p>}
    </main>
  );
}

curl returned Legacy shelf (Pages Router, client-side), the summary fallback and Loading…; after hydration, with localStorage.shelf = "1", the list showed all three books and the summary read 1 saved books (from localStorage). A page without getServerSideProps or getInitialProps is prerendered automatically (Automatic Static Optimization), so this page costs no server work per request.

Table 10. Client-side rendering in the two routers
Topic App Router Pages Router

Default component type

Server Component; add 'use client' for the client side of a boundary.

Every page and component is a Client Component in the App Router sense (rendered on the server, then hydrated).

How a page becomes client-rendered

A Client Component that fetches in an effect or library, or ssr: false for the component.

No getServerSideProps or getStaticProps, and fetch in useEffect or SWR or TanStack Query.

Prerender of the shell

Server-prerendered unless skipped; ○ in the route table.

Automatic Static Optimization; ○ in the route table, an .html file in the build output.

next/dynamic with ssr: false

Client Components only; an error in Server Components since 15.

Allowed in pages and components (same API; the guide says it behaves the same in both directories).

Route params on first render

params and searchParams are Promises on the server; useParams and useSearchParams on the client, the latter under <Suspense>.

For a prerendered page with a dynamic route or query, router.query is empty on the prerender and filled after hydration; use router.isReady.

Client navigation

Soft navigation fetches an RSC payload; shallow routing with history.pushState is integrated.

Soft navigation fetches JSON (_next/data) for pages with data functions and nothing for a page without them; router.push(url, as, { shallow: true }) is the shallow-routing API.

Data libraries

SWR, TanStack Query seeded with fallback or HydrationBoundary.

SWR and TanStack Query (recommended by the official page), seeded through getStaticProps or getServerSideProps props.

Recommended for new work

Yes.

Supported, legacy for new projects.

The shallow-routing API of the Pages Router (router.push with { shallow: true }) is only listed here as a pointer to the router reference; it was not run for this page.

Bundle-Size Impact

Everything on the client side of a 'use client' boundary is JavaScript the user downloads, parses and executes. CSR makes that cost visible: a Server Component sends no JavaScript for itself, a Client Component that fetches and renders on the client sends all of it, plus the libraries it imports. Three tools keep it down.

Load rarely used code on demand. The lazy-loading guide’s example loads fuse.js (a fuzzy-search library) only after the user types. The scratch app compares a static import with a dynamic one in two otherwise identical pages; the numbers are the gzip size of the script files referenced by each route’s HTML:

A static import versus an on-demand import of fuse.js (production build, gzip of the scripts in the HTML)
// components/FuseLazy.tsx
"use client";

import { useState } from "react";

const titles = ["Dune", "Neuromancer", "Solaris", "Hyperion", "Foundation"];

export function FuseLazy() {
  const [hits, setHits] = useState<string[]>([]);
  return (
    <div>
      <input
        aria-label="Fuzzy search"
        onChange={async (e) => {
          const value = e.currentTarget.value;
          const Fuse = (await import("fuse.js")).default; // fetched on first keystroke
          setHits(new Fuse(titles).search(value).map((r) => r.item));
        }}
      />
      <p data-testid="hits">{hits.join(", ")}</p>
    </div>
  );
}
Table 11. Measured on the scratch app (Next.js 16.3.8, Turbopack)
Page Raw JS Gzip Comment

/csr/first (the reference: a tiny Client Component)

566,647 B

174,134 B

The framework, React and the router: the floor for any App Router page.

/csr/bundle-static (import Fuse from "fuse.js" at the top)

592,703 B

183,243 B

About 9 KB gzip more, paid by everybody who opens the page.

/csr/bundle-lazy (await import("fuse.js") in the handler)

566,885 B

174,279 B

About the reference; the library is fetched on the first keystroke.

Use next/dynamic or React.lazy for whole components, for example a modal or a chart that is opened on demand (see Code Splitting and Suspense for the React side). Server Components are split automatically; lazy loading applies to Client Components.

Measure with the analyzer. next build stopped printing per-route size and First Load JS columns in Next.js 16, so size work needs a tool. next experimental-analyze (Turbopack, 16.1 and later, labelled experimental) opens an interactive module graph, and next experimental-analyze --output writes a static report to .next/diagnostics/analyze that can be compared before and after a change. @next/bundle-analyzer is the plugin for webpack builds only.

optimizePackageImports

Some packages (typically icon libraries and component suites) export hundreds or thousands of modules from one barrel file, which slows compilation and, if the package does not tree-shake well, grows the bundle. The experimental option experimental.optimizePackageImports makes Next.js load only the modules you use while you keep writing named imports. It superseded the older modularizeImports mapping in 13.5, and a list of popular packages (lucide-react, date-fns, lodash-es, @mui/icons-material, react-icons/* and others) is optimized by default.

Opting a package in
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: ["@phosphor-icons/react"],
  },
};

export default nextConfig;

Measured honestly: with @phosphor-icons/react 2.1.10 (about 3,000 icon modules behind one barrel, sideEffects: false) and a page importing three icons, the production page was identical with and without the option (574,838 B raw and 176,584 B gzip both times), and the first next dev compile of the route took about 0.85 s without it and 1.2 s with it on this machine. The option is therefore not a bundle-size tool for packages that already tree-shake under Turbopack; reach for it when the build or the dev server is slow because of a barrel file, and measure. The official page still labels it experimental.

SEO, Accessibility and Security of Client-Rendered Content

  • SEO. Data fetched on the client is not in the HTML (the curl outputs above show only loading states). Google documents that it can render JavaScript but as a separate, deferred step, and other crawlers and link-preview bots may not run it. Keep indexable content server-rendered, and put page metadata in the Metadata API of a Server Component, which works even when the body is client-rendered. A static-export SPA has one title for all paths unless the client updates it.

  • Loading states. A client-rendered region needs a skeleton or fallback that matches the final size, or the page jumps when data arrives (layout shift). Announce updates to assistive technology with a live region where content changes after load.

  • Secrets. Nothing a Client Component imports or fetches is private. API keys belong in a Route Handler or Server Action, called with an authenticated cookie; only genuinely public values use the NEXT_PUBLIC_ prefix (see Configuration, TypeScript & Environment Variables).

  • Authorisation. Hiding a route in the client is not protection: the API behind it must check the session on every request. Prefer an httpOnly session cookie to a token in localStorage, which any injected script can read.

Pitfalls

Every row below was either reproduced in the scratch app (the message or output is quoted) or comes from the cited official page.

Table 12. Client-side rendering pitfalls
Pitfall Symptom Fix

Effect fetch without cleanup (reproduced)

Input sol showed the results for s; a stale answer overwrote a newer one.

AbortController aborted in the cleanup, an AbortError ignored, or a library.

Effect fetch that ignores the status (the naive example above)

An error body is parsed as data; a network failure is an unhandled rejection.

Check res.ok, catch, model error state.

Dependent fetches in nested components (measured)

The shelf took about 1006 ms against 58 to 118 ms from the server.

Start on the server and pass a promise to use(), fetch in parallel, or collapse into one endpoint.

Reading window or localStorage while rendering

ReferenceError during prerender, or a hydration mismatch.

Read in an effect, or use ssr: false, use(browser()) or a mounted flag.

ssr: false in a Server Component (reproduced)

Build error: "`ssr: false` is not allowed with next/dynamic in Server Components. Please move it into a Client Component."

Wrap the dynamic call in a Client Component.

use(browser()) without Suspense (reproduced)

Prerender fails: "client rendering was requested outside a Suspense boundary" (React error 603).

Put a <Suspense fallback> above the component.

useSearchParams without Suspense (reproduced)

missing-suspense-with-csr-bailout (previous model) or blocking-prerender-client-hook (Cache Components).

Wrap the Client Component in <Suspense>, or pass the page’s searchParams and use() it.

useSearchParams is nullable with a pages/ directory (reproduced)

TypeScript error: searchParams is possibly null.

Use searchParams?.get(…​) while both routers exist.

SWR fallback key differs from the hook key

The seeded data is ignored and the client fetches again.

Define the key once and import it on both sides.

One TanStack QueryClient shared across server requests

Data from one user’s request can be visible to another.

Create a client per server render; reuse one only in the browser.

await of a Request-time API outside Suspense with Cache Components (reproduced)

blocking-prerender-dynamic build error ("uncached or runtime data during prerendering").

Move connection() or searchParams access into a child under <Suspense>.

Starting next start with a different config than next build (reproduced)

HTTP 500, DYNAMIC_SERVER_USAGE, on routes that were prerendered with Cache Components.

Give both commands the same next.config inputs.

Importing a heavy library at the top of a client file (measured)

About 9 KB gzip added to every visit for fuse.js.

await import("fuse.js") on interaction, or next/dynamic.

Putting 'use client' on a layout or root provider "just in case"

Its whole subtree is client-side code.

Keep the boundary on the smallest interactive component; pass Server Components as children.

Secrets or authorisation in client code

Keys visible in the bundle; "protected" pages still reachable by the API.

Server-side calls through Route Handlers or Server Actions; check the session on every request.

Static-export SPA with deep links

/books/3 returns 404 on a plain static host.

Configure the host to serve index.html for unknown paths (404 to /index.html with status 200).

Static-export SPA with server features

Build error or a feature that silently disappears.

Check the unsupported-features list: cookies, rewrites, redirects, headers, Proxy, Server Actions, ISR.

Indexable content only in client-fetched data

A crawler or preview bot sees a loading state.

Server-render it, or use SSG or SSR for those routes.

Duplicate requests in development

Two requests per mount with Strict Mode.

Expected: it checks that cleanup works. Production does not do it.

What Changed Since Next.js 13 and 14

The idea of CSR did not change; where it is expressed, and the tools around it, did. Versions are the Next.js (or React) release in which the change shipped.

Table 13. Changes that touch client-side rendering
Version Change

13.4 (May 2023)

The App Router became stable: components under app/ are Server Components by default, and 'use client' marks the client side of a boundary. In the Pages Router every component was already a Client Component in this sense.

13.5 (October 2023)

optimizePackageImports superseded the manual modularizeImports mapping.

14.0 (October 2023)

next export was removed; static export is output: 'export'.

14.1 (January 2024)

window.history.pushState and replaceState integrated with the router, so shallow routing works in the App Router and syncs with usePathname and useSearchParams.

15 (October 2024)

ssr: false with next/dynamic disallowed in Server Components. React 19 baseline, so use() can unwrap promises passed from the server. fetch and GET Route Handlers stopped being cached by default.

16.0 (October 2025)

Turbopack became the default bundler for development and build; the per-route size columns left next build output; next lint was removed. The synchronous request APIs were removed, which matters for the client-component props that are Promises (params, searchParams).

16.1 (December 2025)

next experimental-analyze, a Turbopack bundle analyzer (still experimental).

16.3 (August 2026)

Instant Navigations (opt in with cacheComponents and partialPrefetching) add the blocking-prerender-client-hook error for URL hooks outside Suspense. The official client-side data fetching guides (SWR and TanStack Query seeded from the server) and the SPA guide, last updated 2026-08-25, describe the promise-and-use() pattern.

React 19.3 (2026-09-09)

use(browser()) in react-dom: a component that renders only in the browser, with the nearest Suspense fallback in the server HTML.

Outside Next.js, the React team stopped recommending Create React App: the React documentation recommends starting new apps with a framework, and the Next.js migration guides for Create React App and Vite show how to move an existing SPA without a rewrite (the static-export SPA above is the first step of both).

Where the Books Are Outdated

Both requester-provided books are written for the Pages Router (Next.js 12 and 13.2, React 17 and 18). Client-side rendering in them means a page without data functions, a useEffect that fetches, typeof window checks, and next/dynamic with ssr: false. That vocabulary still works, but several parts are outdated or incomplete.

Table 14. Where the books fall short of Next.js 16.3 on this topic
Topic What differs today

Router

Pages Router only. The App Router has Client Components as an explicit boundary, soft navigation with RSC payloads, and a server-started promise that a Client Component unwraps with use(), which makes many client fetches unnecessary.

useEffect fetching

Tutorial-style effects (like the naive example above) usually have no cleanup, no status check and no error state. Use the corrected form here, or SWR or TanStack Query, which the current official Pages Router page recommends over a hand-written effect.

Skipping SSR

typeof window guards and next/dynamic with ssr: false are covered; ssr: false is now an error in Server Components (15), and React 19.3 adds use(browser()).

Shallow routing

router.push(…​, { shallow: true }) in the Pages Router. In the App Router use window.history.pushState and replaceState, which integrate with usePathname and useSearchParams (14.1).

Static export

next export as a command (removed in 14). Use output: 'export', and know the unsupported-features list.

Bundle analysis

@next/bundle-analyzer (webpack only). Turbopack, the default bundler since 16, has next experimental-analyze.

Runtime

React 17 and 18, Node 14 and 16: React 19 and Node 20.9 or later are the baseline, and use() is available.

Anything in a book that stores a token in localStorage or hides an admin page only in client code is wrong for the reasons in the security section above: authorisation belongs on the server, and secrets never belong in client-rendered code.

The pages on static site generation, incremental static regeneration, hydration and partial prerendering, written alongside this one, continue the rendering spectrum; hydration in particular covers the mismatch errors that the techniques in the skip-SSR section avoid, and later pages cover data fetching, caching and data security in depth.