Next.js · 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
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.