Emmanuel Akinfulubi

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.

A server component that reads a file

ran at build time · next build + next start · Next 16.3.1

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.

A client component, what it sent and what it shipped

ran at build time · next build + next start · Next 16.3.1

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.

A server component inside a client component

ran at build time · next build + next start · Next 16.3.1

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.

A provider around a server layout’s children

ran at build time · next build + next start · Next 16.3.1

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.

A Server Action passed as a prop

shown, not run
// 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.

A wrapper for a package without the directive

shown, not run
// 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() from next/headers reads the request's cookies (lesson 21).
  • await searchParams, the page's second prop next to params, is the query string. The request below has none, and the page is dynamic anyway: reading it is what counts.
  • await connection() from next/server reads 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.

Which clock routes the build prerendered

ran at build time · next build · Next 16.3.1

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.

Five clocks, each asked twice

ran at build time · next build + next start · Next 16.3.1

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.

The same page with and without Suspense

ran at build time · next build + next start · Next 16.3.1

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.

The text that moves the comments into place

ran at build time · next build + next start · Next 16.3.1

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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.