Skip to main content
Next.js · lesson 4 of 11
Next.js intermediate Lesson 4 of 11

Also part of: React

Dynamic by Default: Rendering Modes

A Next.js 16 page reading connection() outside Suspense fails a real build; wrapping it in Suspense streams a hole, and instant = false blocks instead.

By SkillByExample · Published

nextjs rendering-modes cache-components suspense

Cache Components turns on a rule for every route, by default: anything reading real request data has to say so explicitly, or the build refuses to ship it. This lesson reproduces that rule as a real build failure, then fixes it two different ways that leave two different markers in the route table. The project setup reuses the Docker pattern from the first lesson — the node:26.13 image, the nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is there.

Reading the Real Request Outside Suspense Fails the Build

Five files set up the project: package.json, next.config.ts, tsconfig.json, and app/layout.tsx are unchanged from the previous lessons; app/page.tsx carries a new comment, since this lesson’s point is the /dynamic route, not the home page.

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "20.19.43",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
  title: "Next App",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx — a plain static home page, nothing request-specific
export default function HomePage() {
  return <h1>Home</h1>;
}

app/dynamic/page.tsx calls connection() from next/server — a function whose only job is to assert “this code only makes sense once a real request has arrived.” Nothing wraps it in a <Suspense> boundary yet:

// app/dynamic/page.tsx — no <Suspense> boundary yet
import { connection } from "next/server";

export default async function DynamicPage() {
  await connection();
  return <p>Timestamp: {Date.now()}</p>;
}

Predict whether next build catches this the same way Server and Client Components showed it catching a missing "use client" directive, before building. npm install is shown, not hidden, so the output below is exactly what a reader’s own machine would print too:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund

added 28 packages in 25s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 85ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 4.2s
  Running TypeScript ...
  Finished TypeScript in 2.7s ...
Error: Route "/dynamic": 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
    at body (<anonymous>)
    at html (<anonymous>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
  - Start the app in development mode by running `next dev`, then open "/dynamic" in your browser to investigate the error.
  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.
Error occurred prerendering page "/dynamic". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /dynamic/page: /dynamic, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

The build fails before emitting any output, and it compiles TypeScript and the component successfully first — this is Cache Components checking a rendering rule, not a type error. The error names the exact route, /dynamic, and then lists every API that can trigger this same rule: fetch, cookies(), headers(), params, searchParams, and connection() — not just the one this page actually called. It also hands back three concrete fixes, labelled [stream], [cache], and [block]. The rest of this lesson tries the first and third; the [cache] option explicitly doesn’t apply here (connection() exists to assert real-request behavior, the opposite of caching) and is the next lesson’s subject instead.

[stream]: a <Suspense> Boundary Fixes It, and Marks the Route ◐

Move the connection() call into its own component, and wrap that component — not the whole page — in <Suspense>:

// app/dynamic/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function Timestamp() {
  await connection();
  return <p>Timestamp: {Date.now()}</p>;
}

export default function DynamicPage() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Timestamp />
    </Suspense>
  );
}

Rebuild to confirm the error from the previous section is gone, and predict which marker /dynamic gets — the same ○ every route in this project has had so far, or something else:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 56ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.8s
  Running TypeScript ...
  Finished TypeScript in 2.4s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /dynamic


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

/dynamic gets ◐, not ○. The build still produces a static HTML shell for the route — the fallback markup, the surrounding layout — but the part wrapped in <Suspense> is left out of that static output and computed fresh on every real request instead. Start the built app, request the page twice two seconds apart, and compare the two captured values directly instead of just eyeballing them:

$ docker run -d --name nextjs-rendering-1 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
1cb62d56fd88efe82b3cd04f64d299a5170f2aeed4682c8e6c9dc9db3c6daa93
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ a=$(curl -s http://localhost:3100/dynamic | grep -oE '<p>Timestamp: [^/]+/p>'); echo "$a"
<p>Timestamp: <!-- -->1793396001499</p>
$ sleep 2; b=$(curl -s http://localhost:3100/dynamic | grep -oE '<p>Timestamp: [^/]+/p>'); echo "$b"
<p>Timestamp: <!-- -->1793396004179</p>
$ [ "$a" != "$b" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-rendering-1
nextjs-rendering-1

The DIFFERENT line only printed because the shell comparison itself found the two captured values unequal — Timestamp really is recomputed on each request, not baked in once at build time. This is the same kind of proof The App Router and the Route Table’s ◐ route (/profile, reading headers()) implied but never ran twice; here the comparison makes “streamed in on every request” checked, not just narrated.

[block]: instant = false Fixes It Differently, and Marks the Route ƒ

The error’s third fix, export const instant = false, skips <Suspense> entirely and tells Cache Components to allow a route with no static shell at all. Add a second page that uses it:

// app/blocking/page.tsx — no <Suspense>, opts out of prerendering a shell instead
import { connection } from "next/server";

export const instant = false;

export default async function BlockingPage() {
  await connection();
  return <p>Timestamp: {Date.now()}</p>;
}

Predict whether /blocking gets the same ◐ that /dynamic just earned, before building:

$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 76ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.2s
  Running TypeScript ...
  Finished TypeScript in 2.1s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /blocking
└ ◐ /dynamic


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

/blocking gets ƒ, the same marker Route Handlers’s /api/hello earned for reading searchParams — (Dynamic) server-rendered on demand. There is no static shell for this route at all, not even a fallback: the entire page is computed on the server for every request, and nothing is sent back until that’s done. /dynamic still shows ◐ right next to it in the same table — same connection() call, two different fixes, two different markers. The difference isn’t just two labels in a table, either; it shows up in what each route actually sends back:

$ docker run -d --name nextjs-rendering-4 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
8cccc14338804b5a0f59ce8c0df7023a1561b10792ead278381477ed7a010b48
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/dynamic | grep -c 'Loading\.\.\.'
2
$ curl -s http://localhost:3100/blocking | grep -c 'Loading\.\.\.'
0
$ docker rm -f nextjs-rendering-4
nextjs-rendering-4

/dynamic’s response contains the literal fallback text, Loading..., twice — once rendered right into the HTML as the static shell Next.js already streamed before the <Suspense> boundary resolved, and once more inside the hydration payload script that describes the same fallback prop for React to reconcile against. /blocking’s response contains it zero times, because there was never a shell (or a fallback prop) to send in the first place: a ◐ route sends a visitor something immediately and streams the hole in afterward; a ƒ route sends nothing back until the whole page, dynamic part included, is ready.

Practice

Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.

1. Add app/theme/page.tsx that reads a cookie with cookies() from next/headers, with no <Suspense> boundary. Predict whether the build’s error message names cookies() specifically or still lists all the same APIs it listed for /dynamic’s connection() error.

Solution
// app/theme/page.tsx — no <Suspense> boundary, on purpose
import { cookies } from "next/headers";

export default async function ThemePage() {
  const cookieStore = await cookies();
  return <p>Theme cookie: {cookieStore.get("theme")?.value ?? "none"}</p>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 73ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 3.3s
  Running TypeScript ...
  Finished TypeScript in 2.9s ...
Error: Route "/theme": 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
    at body (<anonymous>)
    at html (<anonymous>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
  - Start the app in development mode by running `next dev`, then open "/theme" in your browser to investigate the error.
  - Rerun the production build with `next build --debug-prerender` to generate better stack traces.
Error occurred prerendering page "/theme". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /theme/page: /theme, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null

Only the route name changed, from /dynamic to /theme — the list of APIs underneath it is the same fixed text as before, cookies() included, even though this page never called connection() at all. The error template names every API the rule covers, not just the one the failing route happened to use.

2. Fix /theme with a <Suspense> boundary, the same pattern /dynamic used, and confirm it joins /dynamic with the ◐ marker.

Solution
// app/theme/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function ThemeCookie() {
  const cookieStore = await cookies();
  return <p>Theme cookie: {cookieStore.get("theme")?.value ?? "none"}</p>;
}

export default function ThemePage() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ThemeCookie />
    </Suspense>
  );
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 66ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.6s
  Running TypeScript ...
  Finished TypeScript in 2.3s ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /blocking
├ ◐ /dynamic
└ ◐ /theme


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

/theme now shows ◐ right alongside /dynamic — a different request API (cookies() instead of connection()), wrapped the same way, lands on the same marker.

3. Add a second <Suspense>-wrapped read to /dynamic — a random number alongside the timestamp, each in its own boundary — and confirm the route still gets exactly one ◐, not two rows.

Solution
// app/dynamic/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";

async function Timestamp() {
  await connection();
  return <p>Timestamp: {Date.now()}</p>;
}

async function RandomNumber() {
  await connection();
  return <p>Random: {Math.random()}</p>;
}

export default function DynamicPage() {
  return (
    <>
      <Suspense fallback={<p>Loading...</p>}>
        <Timestamp />
      </Suspense>
      <Suspense fallback={<p>Loading...</p>}>
        <RandomNumber />
      </Suspense>
    </>
  );
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 71ms
- Cache Components enabled
- Partial Prefetching enabled

  Creating an optimized production build ...
✓ Compiled successfully in 2.3s
  Running TypeScript ...
  Finished TypeScript in 1997ms ...
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /blocking
├ ◐ /dynamic
└ ◐ /theme


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
$ docker run -d --name nextjs-rendering-2 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
a0fa419a833bead5f43a91955d9cf41a59d7d3dd87d60324637a1b1fd750ca49
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/dynamic | grep -oE '<p>(Timestamp|Random): [^/]+/p>'
<p>Timestamp: <!-- -->1793396062684</p>
<p>Random: <!-- -->0.5386360763699769</p>
$ docker rm -f nextjs-rendering-2
nextjs-rendering-2

Still one row for /dynamic, still ◐ — a route’s marker reflects whether it has any dynamic hole, not how many. Two separate <Suspense> boundaries stream independently but don’t multiply the row the build prints for the page itself.

4. Request the fully static / route twice, two seconds apart, and confirm the HTML comes back byte-for-byte identical — the direct contrast to /dynamic’s changing timestamp.

Solution
$ docker run -d --name nextjs-rendering-3 -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
28b60b20445a5fc2af9b9aad661be5e4d0420ec075aa93343fcd440db5e50901
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ > first.html && sleep 2 && curl -s http://localhost:3100/ > second.html && diff first.html second.html && echo IDENTICAL
IDENTICAL
$ docker rm -f nextjs-rendering-3
nextjs-rendering-3

diff finds no difference at all, and echo IDENTICAL only ran because diff exited 0 — / really was prerendered once, at build time, and next start is just handing back that same static file on every request since. /dynamic could never pass this same check; its whole point is that the number changes.

Frequently Asked Questions

What exactly does Cache Components' "dynamic by default" rule enforce?
That two different categories of data each need their own fix. Request APIs — cookies(), headers(), params, searchParams, and connection() — read something that only exists once a real request has arrived; they can only be fixed by wrapping the read in a <Suspense> boundary or opting the whole route out of prerendering with export const instant = false, never with "use cache". Uncached data — a fetch(...) call or a database read — is the other category, and it can be fixed either the same way or by caching it with "use cache" instead. Next.js catches whichever one happens at prerender time, after TypeScript itself has already compiled successfully — it's a rendering failure, not a compile error.
What is the difference between the ◐ and ƒ markers in a Next.js 16 build's route table?
◐ (Partial Prerender) means the build still produced a static HTML shell for the route, with one or more <Suspense>-wrapped holes that stream in dynamic content on each real request. ƒ (Dynamic) means there is no static shell at all — the entire route is rendered fresh, on the server, for every request, with nothing prerendered ahead of time.
Why does the build's prerender error mention connection() even when the actual code only called cookies()?
The error message is a fixed template that lists every API that can trigger the same rule — fetch, cookies, headers, params, searchParams, and connection — regardless of which one the failing route actually called. The route name in the error's first line (Route "/path": ...) is what actually identifies the offending page; the list of APIs underneath it never changes.
Does "use cache" fix the error from calling connection() outside Suspense?
No. The build's own error message says so directly: the [cache] fix "does not apply to connection()". connection() exists specifically to assert that code is running per-request, which is the opposite of what caching means — fetch calls and database reads are what "use cache" (the next lesson's topic) caches instead.
Does export const instant = false make a route slower than a Partial Prerender?
It changes what a visitor sees before anything else does: instant = false produces the ƒ (Dynamic) marker, meaning the server computes the entire page fresh before sending any bytes back, so a visitor gets nothing until it's done. A Partial Prerender (◐) sends the static shell immediately and streams the dynamic hole in afterward, so a visitor sees something right away. Which one is actually slower in practice depends on how long the dynamic work takes relative to the HTML the shell would have sent first.