Components all 5 written
Server components, client components and the boundary between them, static against dynamic rendering, and streaming.
8 Server components
In the App Router a component is a server component unless a file opts out. It runs on the server, as part of building the page or answering the request, and what the browser gets is the result. That one fact explains most of what is different about a Next.js component.
What a server component can do
Because it runs in Node, a server component can be an async function, can
await a promise at the top, and can use anything Node has: here
readFile from node:fs/promises, as in the
Node.js course. There is no useEffect to
fetch with and no loading state to keep, because the data is there before the HTML is. The
React course explains the model; this lesson
checks what it does to the output.
The page below calls getShelf(), which reads data/shelf.json from
the project folder. The function lives in app/_lib/shelf.js and starts with
import 'server-only'. Each book it returns also carries a note
that the page does not render.
app/library/page.js
import { getShelf } from '../_lib/shelf.js';
export default async function Library() {
const shelf = await getShelf();
return (
<main data-page="library">
<h1>Library</h1>
<ul>
{shelf.map((book) => (
<li key={book.title}>
{book.title}, shelved {book.shelved}
</li>
))}
</ul>
</main>
);
}
app/_lib/shelf.js
import 'server-only';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function getShelf() {
const text = await readFile(join(process.cwd(), 'data', 'shelf.json'), 'utf8');
// `note` is data the page has, and chooses not to render.
return JSON.parse(text).map((book) => ({ ...book, note: 'internal-note-7c1e4d' }));
}
server-component.mjs
import { existsSync, readdirSync, readFileSync } from 'node:fs';
const root = process.env.APP_DIR + '/.next';
const html = await (await fetch(process.env.BASE_URL + '/library')).text();
const walk = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(dir + '/' + e.name) : [dir + '/' + e.name]));
const countIn = (files, text) => files.filter((f) => readFileSync(f, 'utf8').includes(text)).length;
const client = walk(root + '/static/chunks').filter((f) => f.endsWith('.js'));
const server = walk(root + '/server').filter((f) => /\.(js|html)$/.test(f));
const manifest = readFileSync(root + '/server/app/library/page_client-reference-manifest.js', 'utf8');
const listed = [...manifest.matchAll(/"\[project\]\/(app\/[^"]+)":\{"id"/g)].map((m) => m[1]);
console.log(html.match(/<ul>.*?<\/ul>/)[0].replace(/<!-- -->/g, ''));
console.log('the note is in the HTML:', html.includes('internal-note-7c1e4d'));
console.log('files under .next/server holding the note:', countIn(server, 'internal-note-7c1e4d') > 0);
console.log('files under .next/static/chunks holding it:', countIn(client, 'internal-note-7c1e4d'));
check('the data read from disk is in the HTML', ['Middlemarch', 'Dracula', 'Persuasion', '2024-05-30'].map((s) => html.includes(s)), [true, true, true, true]);
check('a field the component did not render is not in the HTML', html.includes('internal-note-7c1e4d'), false);
check('the code that holds it is in the server build', countIn(server, 'internal-note-7c1e4d') > 0, true);
check('and in no file the browser downloads', countIn(client, 'internal-note-7c1e4d'), 0);
check('neither is the page itself', countIn(client, 'shelved'), 0);
check('the page needs no client module of the project', listed, []);
check('server-only is not a folder in node_modules, and the build passed anyway', existsSync(process.env.APP_DIR + '/node_modules/server-only'), false);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node server-component.mjs
<ul><li>Middlemarch, shelved 2024-03-01</li><li>Dracula, shelved 2024-04-12</li><li>Persuasion, shelved 2024-05-30</li></ul> the note is in the HTML: false files under .next/server holding the note: true files under .next/static/chunks holding it: 0 ok the data read from disk is in the HTML ok a field the component did not render is not in the HTML ok the code that holds it is in the server build ok and in no file the browser downloads ok neither is the page itself ok the page needs no client module of the project ok server-only is not a folder in node_modules, and the build passed anyway
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The titles and dates came from a file the browser never sees and has no way to ask for. The
note was read, held in memory, and left out of the output: it exists in the server build and in
no file under .next/static, which is the folder the browser downloads. Nothing in
the page needed JavaScript in the browser, so the manifest that lists the client modules of
this page names no file of the project (the next lesson has a page where it does).
The server-only marker
A module that is imported by a server component is not thereby kept out of the browser: a
client component can import it too, and then its code ships. Putting
import 'server-only' at the top of a module is a marker for that case. The
vendored guide
(next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md) says
installing the server-only package is optional in Next.js, and the last check
above agrees: the import works with no such folder in node_modules, so the framework
supplies it. This course does not run the failing case here.
What a server component cannot do
It renders once, so it has no state to change and no event to wait for. The same guide lists
when a client component is needed: state and event handlers such as onClick,
lifecycle logic such as useEffect, and browser-only APIs such as
localStorage and window. A component that needs any of them goes behind the
"use client" line in lesson 9. A server component can still contain client
components, and lesson 10 shows how the two nest.
9 Client components and the "use client" boundary
A server component cannot hold state or react to a click, because
nothing in it runs in the browser. A client component can, and the line that makes one
is "use client" at the top of its file.
What the line does
It marks a boundary. The file with the line, and every module that file imports, is part of
the JavaScript the browser downloads. Components below the line behave as the
React course teaches, with state, effects and event
handlers. The vendored guide
(next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md)
calls the set of modules a client component pulls in its module graph.
A client component is still rendered once on the server, so the HTML has its first state in it and the page is readable before any script runs. After the scripts load, React hydrates the HTML: it attaches the handlers to what is already there (the React course covers that step). Hydration happens in a browser. The panel shows what the server sent and what was shipped, and nothing here clicks the button.
The page app/counter/page.js is a server component. It renders
Counter with a label, a start value and a list of tags. Counter
imports describe.js, which has no directive of its own.
app/counter/page.js
import Counter from './Counter';
export default function CounterPage() {
return (
<main data-page="counter">
<h1>Counter</h1>
<Counter label="Copies" start={3} tags={['fiction', 'classic']} />
</main>
);
}
app/counter/Counter.js
'use client';
import { useState } from 'react';
import { describe } from './describe.js';
export default function Counter({ label, start, tags }) {
const [count, setCount] = useState(start);
return (
<p data-counter>
{label}: <output>{count}</output> ({tags.join(', ')}){' '}
<button onClick={() => setCount(count + 1)}>{describe(count)}</button>
</p>
);
}
app/counter/describe.js
// No "use client" here. It is in the browser's code because a client component imports it.
export function describe(count) {
return count === 1 ? 'add (describe-marker-2b9f, 1 copy)' : `add (describe-marker-2b9f, ${count} copies)`;
}
client-component.mjs
import { readdirSync, readFileSync } from 'node:fs';
const root = process.env.APP_DIR + '/.next';
const html = await (await fetch(process.env.BASE_URL + '/counter')).text();
/* The payload is a list of JSON string pieces inside script tags. */
const payload = [...html.matchAll(/self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g)].map((m) => JSON.parse(m[1])).join('');
const manifest = readFileSync(root + '/server/app/counter/page_client-reference-manifest.js', 'utf8');
const listed = [...manifest.matchAll(/"\[project\]\/(app\/[^"]+)":\{"id"/g)].map((m) => m[1]);
const chunks = readdirSync(root + '/static/chunks').filter((f) => f.endsWith('.js'));
const holding = (text) => chunks.filter((f) => readFileSync(root + '/static/chunks/' + f, 'utf8').includes(text)).length;
console.log('the server rendered:', html.match(/<output>.*?<\/output>/)[0]);
console.log('props in the payload:', payload.match(/\{"label":[^}]*\}/)[0]);
console.log('client modules of the project, per the manifest:', listed.join(' '));
console.log('files under .next/static/chunks that hold describe.js:', holding('describe-marker-2b9f'));
check('the HTML already holds the starting state', html.includes('<output>3</output>'), true);
check('the props travel as JSON in the payload', payload.includes('{"label":"Copies","start":3,"tags":["fiction","classic"]}'), true);
check('the manifest names the marked file and nothing else of the project', listed, ['app/counter/Counter.js']);
check('the unmarked module it imports is not listed', listed.includes('app/counter/describe.js'), false);
check('but its code was shipped to the browser with Counter', holding('describe-marker-2b9f'), 1);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node client-component.mjs
the server rendered: <output>3</output>
props in the payload: {"label":"Copies","start":3,"tags":["fiction","classic"]}
client modules of the project, per the manifest: app/counter/Counter.js
files under .next/static/chunks that hold describe.js: 1
ok the HTML already holds the starting state
ok the props travel as JSON in the payload
ok the manifest names the marked file and nothing else of the project
ok the unmarked module it imports is not listed
ok but its code was shipped to the browser with Counter
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The server rendered 3, the value of the start prop, and wrote the
props into the payload as JSON. The manifest is how Next.js knows which modules of the project
are client modules; it lists Counter.js, the file that carries the directive. The
helper has no directive and is not listed, yet its code is in a browser file, because
Counter.js imports it. The directive goes on the entry point of the client
code, and the modules it imports follow it.
Keep the boundary low
Everything behind the line ships, so put the line in the smallest component that needs it. A
page that only needs one interactive button should be a server component that renders a client
button, as CounterPage does, and not a client page that happens to contain one. The
same applies to NavLink in the last lesson: only the link that needs
usePathname is a client component, and the menu around it is not.
Props must survive the trip
The props of a client component are written into the payload, so what you pass has to be something React can serialise. The guide puts it as a note: props passed to client components need to be serializable by React. Strings, numbers, booleans, and arrays and objects built from them are; the React course says more about what crosses. A Server Action is a function that can be passed as a prop too; lesson 17 covers it.
10 Mixing the two
Real pages use both kinds of component together. The rule is one-way: client code cannot import a server component. The ways around it are what this lesson shows, and each of them is checked in the build output of one page.
Server components as children of a client component
The vendored guide notes that components a client component imports join the
browser's code, while server components passed to it as children or other props are
not imported into its module graph. They are rendered on the server and handed over as finished
output. Collapsible is a client component with a button and a piece of state. The
page puts a server component, ServerNote, between its tags.
app/studio/page.js
import Collapsible from './Collapsible';
import ServerNote from './ServerNote';
import { ThemeBadge } from './ThemeProvider';
export default function Studio() {
return (
<main data-page="studio">
<h1>Studio</h1>
<ThemeBadge />
<Collapsible title="Notes">
<ServerNote />
</Collapsible>
</main>
);
}
app/studio/Collapsible.js
'use client';
import { useState } from 'react';
export default function Collapsible({ title, children }) {
const [open, setOpen] = useState(true);
return (
<section data-collapsible>
<button onClick={() => setOpen(!open)}>{title}</button>
{open ? children : null}
</section>
);
}
app/studio/ServerNote.js
// A server component: no directive, so nothing here is sent to the browser as code.
export default function ServerNote() {
return <p data-server-note>server-child-marker-4e7a, rendered on the server</p>;
}
children.mjs
import { readdirSync, readFileSync } from 'node:fs';
const root = process.env.APP_DIR + '/.next';
const html = await (await fetch(process.env.BASE_URL + '/studio')).text();
const manifest = readFileSync(root + '/server/app/studio/page_client-reference-manifest.js', 'utf8');
const listed = [...manifest.matchAll(/"\[project\]\/(app\/[^"]+)":\{"id"/g)].map((m) => m[1]).sort();
const chunks = readdirSync(root + '/static/chunks').filter((f) => f.endsWith('.js'));
const holding = (text) => chunks.filter((f) => readFileSync(root + '/static/chunks/' + f, 'utf8').includes(text)).length;
console.log(html.match(/<section data-collapsible.*?<\/section>/)[0]);
console.log('client modules of the project:', listed.join(' '));
console.log('browser files holding the server child:', holding('server-child-marker-4e7a'));
check('the server child is inside the client component in the HTML', html.includes('<section data-collapsible="true"><button>Notes</button><p data-server-note="true">server-child-marker-4e7a, rendered on the server</p></section>'), true);
check('the client modules of the page are the two marked files', listed, ['app/studio/Collapsible.js', 'app/studio/ThemeProvider.js']);
check('the server component is not one of them', listed.includes('app/studio/ServerNote.js'), false);
check('and its code is in no file the browser downloads', holding('server-child-marker-4e7a'), 0);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node children.mjs
<section data-collapsible="true"><button>Notes</button><p data-server-note="true">server-child-marker-4e7a, rendered on the server</p></section> client modules of the project: app/studio/Collapsible.js app/studio/ThemeProvider.js browser files holding the server child: 0 ok the server child is inside the client component in the HTML ok the client modules of the page are the two marked files ok the server component is not one of them ok and its code is in no file the browser downloads
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The note is in the HTML, inside the collapsible's section, and its code is not in the
browser's files. When the button is clicked in a browser, Collapsible changes its
state and drops or restores children, which are already rendered output; nothing is
fetched, because the server had done its part. Hiding the section would not have to ask the
server again. (That is browser behaviour, so it is a statement about the design here, not
something the panel ran.)
Context providers
The guide says React context is not supported in server components, so a provider is a small
client component that takes children. The layout of the studio folder is a server
component that places the provider around everything below it. ThemeBadge, another
client component, reads the value with useContext (the
React course explains context). The default value
in createContext('light') is light, so seeing dark in the
output means the provider's value arrived.
app/studio/layout.js
import ThemeProvider from './ThemeProvider';
export default function StudioLayout({ children }) {
return <ThemeProvider theme="dark">{children}</ThemeProvider>;
}
app/studio/ThemeProvider.js
'use client';
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
export default function ThemeProvider({ theme, children }) {
return <ThemeContext value={theme}>{children}</ThemeContext>;
}
export function ThemeBadge() {
const theme = useContext(ThemeContext);
return <span data-theme={theme}>theme: {theme}</span>;
}
provider.mjs
const html = await (await fetch(process.env.BASE_URL + '/studio')).text();
const badge = html.match(/<span data-theme="[^"]*">.*?<\/span>/)[0].replace(/<!-- -->/g, '');
console.log(badge);
check('the badge got the value the provider was given', html.includes('<span data-theme="dark">'), true);
check('not the default from createContext', html.includes('data-theme="light"'), false);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node provider.mjs
<span data-theme="dark">theme: dark</span> ok the badge got the value the provider was given ok not the default from createContext
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The provider lives in the layout, so every page of the studio folder can use the theme, and the layout itself did not have to become a client component. Putting the provider as deep as it can go keeps the rest of the tree server-rendered.
Two more patterns, shown
A Server Action, a function with "use server" in it, can be passed from a server
component to a client one as a prop; lesson 17 runs one. A package that uses
state but was published without a directive needs a thin wrapper that adds it, as the guide
shows for a carousel. Neither can be run here: actions are the subject of a later lesson, and
no third-party UI package is vendored.
// app/studio/page.js, a server component
import SaveButton from './SaveButton';
async function save(formData) {
'use server';
// runs on the server when the form is submitted
}
export default function Page() {
return <SaveButton action={save} />;
}
Server Actions are covered, and run, in lessons 17 and 18. This shows only where the function is defined and where it is passed.
// app/studio/CarouselWrapper.js
'use client';
import { Carousel } from 'acme-carousel';
export default function CarouselWrapper(props) {
return <Carousel {...props} />;
}
acme-carousel is the made-up package name used in the vendored guide. The course vendors no UI package, so nothing here can be imported or run.
11 Static and dynamic rendering
Next.js decides, route by route, whether to render at build time or on each request. It does not ask you; it looks at what the route reads. This lesson makes the difference visible with a page that prints the moment it was rendered.
Static: rendered once, at build
The static clock page reads nothing that depends on a request. next build runs
it, keeps the HTML, and next start sends that HTML again for every visitor. This
is the same thing renderToString does in the
React course, done ahead of time and stored.
The time on the page is the time of the build.
Dynamic: rendered on each request
A route becomes dynamic when it uses data that exists only once a request arrives, or when it says so. The four pages here show each way:
export const dynamic = 'force-dynamic'says so.await cookies()fromnext/headersreads the request's cookies (lesson 21).await searchParams, the page's second prop next toparams, is the query string. The request below has none, and the page is dynamic anyway: reading it is what counts.await connection()fromnext/serverreads nothing but marks the route as needing a real request. It is the way to say "not at build time" when the page has no other reason to be dynamic, such as a page that fetches (lesson 13).
The table that next build prints marks the difference with a symbol. This is the
same single build as in lessons 3 and 5, with the five clock lines checked, and a script that
reads the list of prerendered routes from .next.
prerendered.mjs
import { readFileSync } from 'node:fs';
const manifest = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/prerender-manifest.json', 'utf8'));
const clocks = Object.keys(manifest.routes).filter((route) => route.startsWith('/clock/'));
console.log('prerendered clock routes:', clocks.join(' '));
check('only the static clock was prerendered', clocks, ['/clock/static']);
What next build printed (Next 16.3.1), then the checks, when the build ran it
next build
▲ Next.js 16.3.1 (Turbopack) - Environments: .env.local, .env.production, .env ✓ Running next.config.mjs took <t> Creating an optimized production build ... ✓ Compiled successfully in <t> Running TypeScript ... Finished TypeScript in <t> ... Collecting page data using <n> workers ... ✓ Generating static pages using <n> workers (59/59) in <t> Finalizing page optimization ... Route (app) Revalidate Expire ┌ ○ / ├ ○ /_not-found ├ ○ /about ├ ƒ /account ├ ƒ /actions ├ ○ /actions/done ├ ƒ /api/books ├ ƒ /api/books/[id] ├ ƒ /api/cookie ├ ƒ /api/hello ├ ƒ /api/me ├ ƒ /api/revalidate ├ ○ /api/static ├ ƒ /api/time ├ ƒ /authors/[name] ├ ○ /books ├ /books/[slug] │ ├ ● /books/middlemarch │ ├ ● /books/dracula │ └ ● /books/persuasion ├ ƒ /cache/default ├ ƒ /cache/forced ├ ○ /cache/isr 2s 1y ├ ○ /cache/static ├ ƒ /cache/timed ├ ƒ /cache/unstable ├ ƒ /clock/connection ├ ƒ /clock/cookies ├ ƒ /clock/dynamic ├ ƒ /clock/search ├ ○ /clock/static ├ ○ /counter ├ ○ /dashboard ├ ○ /dashboard/settings ├ ƒ /data/basic ├ ƒ /data/memo ├ ƒ /data/parallel ├ ƒ /data/waterfall ├ ƒ /docs/[...parts] ├ ƒ /env ├ ○ /env/baked ├ ○ /env/client ├ ○ /env/leak ├ ƒ /flaky ├ ○ /forms/signup ├ ƒ /forms/thanks ├ ƒ /guide/[[...parts]] ├ ○ /images ├ ○ /library ├ ƒ /login ├ ○ /meta ├ ƒ /meta/[slug] ├ ○ /moved ├ ○ /old-shelf ├ ○ /pricing ├ ƒ /proxy-demo/new ├ ƒ /reports ├ ƒ /request ├ ƒ /request/bad-set ├ ○ /robots.txt ├ ƒ /signup ├ ○ /sitemap.xml ├ ƒ /streaming ├ ƒ /streaming/blocking ├ ○ /studio ├ ○ /styled ├ ƒ /tags/action ├ ƒ /tags/list └ ○ /tags/path ƒ Proxy (Middleware) ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ƒ (Dynamic) server-rendered on demand ok the log shows "○ /clock/static" ok the log shows "ƒ /clock/dynamic" ok the log shows "ƒ /clock/cookies" ok the log shows "ƒ /clock/search" ok the log shows "ƒ /clock/connection" prerendered clock routes: /clock/static ok only the static clock was prerendered
The check script ran after the build, with APP_DIR set to the project, and read the files in .next.
Now the requests. Each address is requested twice, 30 ms apart, and the page's
data-at attribute (the value of Date.now() when it rendered) is
compared. The driver prints whether the two matched, never the times themselves, which differ
on every run.
app/clock/static/page.js
export default function StaticClock() {
return <main data-page="clock-static"><p data-at={Date.now()}>rendered at {Date.now()}</p></main>;
}
app/clock/dynamic/page.js
export const dynamic = 'force-dynamic';
export default function DynamicClock() {
return <main data-page="clock-dynamic"><p data-at={Date.now()}>rendered at {Date.now()}</p></main>;
}
app/clock/cookies/page.js
import { cookies } from 'next/headers';
export default async function CookieClock() {
const jar = await cookies();
return <main data-page="clock-cookies"><p data-at={Date.now()}>{jar.size} cookie(s), rendered at {Date.now()}</p></main>;
}
app/clock/search/page.js
export default async function SearchClock({ searchParams }) {
const query = await searchParams;
return <main data-page="clock-search"><p data-at={Date.now()}>{Object.keys(query).length} parameter(s), rendered at {Date.now()}</p></main>;
}
app/clock/connection/page.js
import { connection } from 'next/server';
export default async function ConnectionClock() {
await connection();
return <main data-page="clock-connection"><p data-at={Date.now()}>rendered at {Date.now()}</p></main>;
}
clocks.mjs
const base = process.env.BASE_URL;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function twice(path) {
const stamps = [];
let first;
for (let i = 0; i < 2; i++) {
const res = await fetch(base + path);
const html = await res.text();
stamps.push(/data-at="(\d+)"/.exec(html)[1]);
first ??= res;
await sleep(30);
}
return { same: stamps[0] === stamps[1], cache: first.headers.get('cache-control'), hit: first.headers.get('x-nextjs-cache') };
}
const DYNAMIC = 'private, no-cache, no-store, max-age=0, must-revalidate';
const results = {};
for (const path of ['/clock/static', '/clock/dynamic', '/clock/cookies', '/clock/search', '/clock/connection']) {
results[path] = await twice(path);
console.log(path.padEnd(18), 'same time twice:', String(results[path].same).padEnd(5), 'cache-control:', results[path].cache);
}
check('the static page shows the build time on every request', results['/clock/static'].same, true);
check('and is served as cacheable for a year', [results['/clock/static'].cache, results['/clock/static'].hit], ['s-maxage=31536000', 'HIT']);
for (const path of ['/clock/dynamic', '/clock/cookies', '/clock/search', '/clock/connection']) {
check(path + ' renders again on each request', results[path].same, false);
check(path + ' is sent with no-store headers and no cache marker', [results[path].cache, results[path].hit], [DYNAMIC, null]);
}
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node clocks.mjs
/clock/static same time twice: true cache-control: s-maxage=31536000 /clock/dynamic same time twice: false cache-control: private, no-cache, no-store, max-age=0, must-revalidate /clock/cookies same time twice: false cache-control: private, no-cache, no-store, max-age=0, must-revalidate /clock/search same time twice: false cache-control: private, no-cache, no-store, max-age=0, must-revalidate /clock/connection same time twice: false cache-control: private, no-cache, no-store, max-age=0, must-revalidate ok the static page shows the build time on every request ok and is served as cacheable for a year ok /clock/dynamic renders again on each request ok /clock/dynamic is sent with no-store headers and no cache marker ok /clock/cookies renders again on each request ok /clock/cookies is sent with no-store headers and no cache marker ok /clock/search renders again on each request ok /clock/search is sent with no-store headers and no cache marker ok /clock/connection renders again on each request ok /clock/connection is sent with no-store headers and no cache marker
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The static page returns the same moment every time, because it is a stored file; its
cache-control says shared caches may keep it for a year, and
x-nextjs-cache: HIT says it came from Next.js's stored copy. The four dynamic
pages render per request, and tell every cache not to keep the result. A page can be static
for a limited time instead of forever (lesson 14) and can be refreshed on demand (lesson 15).
Choosing
Static is cheaper to serve and faster, and it is right for anything every visitor sees the
same. Dynamic is for what depends on who is asking or on data that must be fresh. You rarely
choose directly: reading cookies() or searchParams makes the choice
for you, so a dynamic page found in the table that you expected to be static usually means
something in it, or in a component it renders, read request data. The table is the place to
look.
12 Streaming and Suspense
A page that fetches slow data has two options. It can wait for
everything and send the page once, or it can send what it has and the rest when it is ready.
The second is streaming, and the part you write is a Suspense
boundary.
Waiting, and not waiting
An HTTP response can be sent in pieces; a Node server does it by writing to the response
more than once (the Node.js course
covers streams). React uses that for server rendering: when a component below a
Suspense boundary is not ready, React renders the boundary's
fallback in its place, sends the page so far, and sends the component's output when
it resolves. The React course shows the same
mechanism with React alone.
The two pages below are the same except for one wrapper. Each has a heading and a sentence
that need nothing, then a Comments component that waits 1000 ms for the data
server. Only the first puts Suspense around it. The driver reads each response with
the reader fetch gives, one piece at a time, and prints booleans about what each
piece held and how long the wait was.
app/streaming/page.js
import { Suspense } from 'react';
export const dynamic = 'force-dynamic';
async function Comments() {
const res = await fetch(`${process.env.DATA_URL}/slow?ms=1000&part=comments`);
const { waited } = await res.json();
return <p data-part="comments">Comments arrived after {waited} ms.</p>;
}
export default function Streaming() {
return (
<main data-page="streaming">
<h1>Streaming</h1>
<p data-part="shell">This part does not wait for anything.</p>
<Suspense fallback={<p data-part="fallback">Loading comments...</p>}>
<Comments />
</Suspense>
</main>
);
}
app/streaming/blocking/page.js
export const dynamic = 'force-dynamic';
async function Comments() {
const res = await fetch(`${process.env.DATA_URL}/slow?ms=1000&part=blocking`);
const { waited } = await res.json();
return <p data-part="comments">Comments arrived after {waited} ms.</p>;
}
export default function Blocking() {
return (
<main data-page="blocking">
<h1>Blocking</h1>
<p data-part="shell">This part has to wait for the comments.</p>
<Comments />
</main>
);
}
streams.mjs
const base = process.env.BASE_URL;
async function read(path) {
const started = performance.now();
const res = await fetch(base + path);
const headersAt = performance.now() - started;
const reader = res.body.getReader();
const decoder = new TextDecoder();
const chunks = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push({ at: performance.now() - started, text: decoder.decode(value, { stream: true }) });
}
return { res, headersAt, chunks, all: chunks.map((c) => c.text).join('') };
}
const streamed = await read('/streaming');
const blocking = await read('/streaming/blocking');
const commentsAt = (r) => r.chunks.findIndex((c) => c.text.includes('Comments arrived'));
const gap = (r) => r.chunks[commentsAt(r)].at - r.chunks[0].at;
const first = streamed.chunks[0].text;
console.log('with Suspense:');
console.log(' first piece has the shell and the fallback:', first.includes('data-part="shell"') && first.includes('Loading comments...'));
console.log(' first piece has the comments: ', first.includes('Comments arrived'));
console.log(' the comments came more than 700 ms after the first piece:', gap(streamed) > 700);
console.log('without Suspense:');
console.log(' the response started more than 700 ms in:', blocking.headersAt > 700);
console.log(' first piece has the comments: ', blocking.chunks[0].text.includes('Comments arrived'));
check('both responses are sent in pieces', [streamed.res.headers.get('transfer-encoding'), blocking.res.headers.get('transfer-encoding')], ['chunked', 'chunked']);
check('with Suspense, the first piece holds the shell and the fallback', [first.includes('data-part="shell"'), first.includes('Loading comments...')], [true, true]);
check('and not the comments', first.includes('Comments arrived'), false);
check('the comments arrive in a later piece', commentsAt(streamed) > 0, true);
check('and they come more than 700 ms after the first piece', gap(streamed) > 700, true);
check('without Suspense, nothing is sent until the data is ready', blocking.headersAt > 700, true);
check('and the first piece already holds the comments', blocking.chunks[0].text.includes('Comments arrived'), true);
check('no boundary was needed there', [blocking.all.includes('id="B:0"'), blocking.all.includes('$RC(')], [false, false]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node streams.mjs
with Suspense: first piece has the shell and the fallback: true first piece has the comments: false the comments came more than 700 ms after the first piece: true without Suspense: the response started more than 700 ms in: true first piece has the comments: true ok both responses are sent in pieces ok with Suspense, the first piece holds the shell and the fallback ok and not the comments ok the comments arrive in a later piece ok and they come more than 700 ms after the first piece ok without Suspense, nothing is sent until the data is ready ok and the first piece already holds the comments ok no boundary was needed there
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
Both responses are chunked; that is not the difference. The difference is when the first byte leaves the server. With the boundary, the visitor gets the heading and the fallback at once and the comments about a second later. Without it, they get nothing for a second and then everything. What changes is how much of the page can be shown, and read, while the rest is on its way.
What the swap looks like in the HTML
The response is one HTML document, written in pieces. The fallback is in the page where the
boundary is, behind a marker comment and an empty template element that names the
boundary. When the data arrives, the server appends the real output in a hidden
div and an inline script call, $RC, that names the two. In a browser
that script moves the content into place. The driver here has no browser, so it can show the
text that carries the instruction, and not the move itself.
swap.mjs
const html = await (await fetch(process.env.BASE_URL + '/streaming')).text();
const placeholder = html.match(/<!--\$\?--><template id="B:0"><\/template>.*?<!--\/\$-->/)[0];
const content = html.match(/<div hidden id="S:0">.*?<\/div>/)[0];
const swap = html.match(/\$RC\("B:0","S:0"\)/)[0];
console.log(placeholder);
console.log(content);
console.log(swap);
check('the fallback sits where the boundary is, next to a marker for it', placeholder, '<!--$?--><template id="B:0"></template><p data-part="fallback">Loading comments...</p><!--/$-->');
check('the finished content arrives hidden, with a name that pairs with the marker', content.includes('Comments arrived after'), true);
check('and a call names both', swap, '$RC("B:0","S:0")');
check('the content comes after the fallback in the document', html.indexOf('id="S:0"') > html.indexOf('id="B:0"'), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node swap.mjs
<!--$?--><template id="B:0"></template><p data-part="fallback">Loading comments...</p><!--/$-->
<div hidden id="S:0"><p data-part="comments">Comments arrived after <!-- -->1000<!-- --> ms.</p></div>
$RC("B:0","S:0")
ok the fallback sits where the boundary is, next to a marker for it
ok the finished content arrives hidden, with a name that pairs with the marker
ok and a call names both
ok the content comes after the fallback in the document
The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.
The marker B:0 is boundary number zero on the page and S:0 is its
replacement. The lesson 7 panel for loading.js showed the same
B:0: that file is a Suspense boundary that Next.js writes around the
page for you. Writing the boundary yourself, around only the slow component, lets the rest of
the page, here the heading, arrive first. Put it as close to the slow part as you can. The
React course covers Suspense and its other uses in
its async lesson.
Both pages are force-dynamic: the slow part has to happen on each request to be
worth streaming. Lesson 16 returns to streaming with Cache Components.