Next.js · lesson 2 of 11
Also part of: React
Server and Client Components
A Server Component that imports useState fails a real Next.js 16 build; adding use client fixes it, and a console.log proves where each component ran.
By SkillByExample · Published
Every file under app/ is a Server Component by default —
The App Router and the Route Table covered the route
table those files produce; this one covers what actually runs on the server versus in a
browser. The project setup below reuses the Docker pattern from that 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.
A Server Component Runs in Node, Not a Browser
Four files set up the project: package.json pins the dependencies, next.config.ts turns on
Cache Components, tsconfig.json configures TypeScript, and app/layout.tsx is the root
layout every page renders inside.
{
"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 is an ordinary async function with no directive at all — a Server Component —
and it reads process.version, a Node global that doesn’t exist in a browser:
// app/page.tsx
export default async function HomePage() {
console.log("HomePage rendering on the server");
return (
<main>
<h1>Home</h1>
<p>Node version on the server: {process.version}</p>
</main>
);
}
npm install is shown, not hidden, so the output below is exactly what a reader’s own
machine would print too, and the build’s progress counters are filtered out the same way
The App Router and the Route Table filtered them.
next build then prerenders the page, since cacheComponents: true only forces a route
dynamic once something in it reads real request data — nothing here does:
$ 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 43s
$ 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"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 118ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 8.1s
Running TypeScript ...
Finished TypeScript in 4.7s ...
HomePage rendering on the server
HomePage rendering on the server
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
HomePage rendering on the server prints twice during the build — not once per worker or
by accident: with cacheComponents: true turned off, the same build logs it only once, so
it’s specifically Cache Components’ static-generation step that renders this page twice while
producing its static output. Either way, both renders happen in Node while the build runs,
never in a browser, since nothing here will ever ship as a browser-side script. Starting the
built app and asking it for the page proves what landed in the HTML:
$ docker run -d --name nextjs-server-components-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
a12c7606773bcb3452f851e03e33429ef7460ce358552c2056903911ef02b5dc
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<p>Node version on the server: [^/]+/p>'
<p>Node version on the server: <!-- -->v26.13.0</p>
$ docker rm -f nextjs-server-components-1
nextjs-server-components-1
The HTML really does contain v26.13.0 — the exact Node version this container runs — baked
in at build time. process.version only exists because the function executing it is Node
code; a browser has no such global, which is exactly why this component is only allowed to
run on the server. The <!-- --> between the label and the version isn’t a stray comment —
React inserts it to separate two adjacent text nodes (the literal string and the
{process.version} expression) so hydration can tell where one ends and the other begins;
it never shows up on the rendered page, only in the raw HTML this curl is reading.
A Server Component Can’t Use useState — the Build Enforces It
Add a counter button. useState depends on re-rendering in response to a click, something
only a browser does, but nothing marks this new file as a Client Component yet:
// app/counter.tsx — missing "use client"
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
// app/page.tsx
import Counter from "./counter";
export default async function HomePage() {
console.log("HomePage rendering on the server");
return (
<main>
<h1>Home</h1>
<p>Node version on the server: {process.version}</p>
<Counter />
</main>
);
}
app/page.tsx imports app/counter.tsx, and app/counter.tsx imports useState straight
from react, with no "use client" directive anywhere in either file. Predict whether the
build catches this before guessing what the error says. Turbopack’s code frame below colors
its output even though nothing here is an interactive terminal — sed strips those escape
codes so the block below shows plain text instead of raw \x1b[...m sequences; on a real
terminal, the code frame lines appear in color:
$ 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 | sed 's/\x1b\[[0-9;]*m//g'
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 209ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./app/counter.tsx:2:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
1 | // app/counter.tsx — missing "use client"
> 2 | import { useState } from "react";
| ^^^^^^^^
3 |
4 | export default function Counter() {
5 | const [count, setCount] = useState(0);
Ecmascript file had an error
Import trace:
Server Component:
./app/counter.tsx
./app/page.tsx
at ignore-listed frames
The build fails before writing any output, and it names the exact file and line — useState
on line 2 of app/counter.tsx, right after the header comment — rather than app/page.tsx,
which is where <Counter /> is actually rendered. The “Import trace” at the bottom shows why:
app/counter.tsx carries no directive of its own, so it’s a Server Component only because
app/page.tsx — the Server Component that imports it — never opts into the client boundary
either. The importer’s side decides an imported file’s side whenever that file has no
directive of its own; that’s exactly why the error itself offers two fixes, “mark the file
(or its parent)” — either one moves the boundary.
Fixing It: the “use client” Directive
// app/counter.tsx
"use client";
import { useState } from "react";
export default function Counter() {
console.log("Counter rendering");
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
The directive has to come before any import or other code — a comment may come first, as the
header comment above it does here, but nothing else can. Rebuild to confirm it fixes the error
from the previous section, and watch where Counter rendering shows up:
$ 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"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 173ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.6s
Running TypeScript ...
Finished TypeScript in 4.0s ...
HomePage rendering on the server
Counter rendering
HomePage rendering on the server
Counter rendering
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
Counter rendering prints right alongside HomePage rendering on the server — during the
build, on the server, exactly like the Server Component. "use client" does not mean
“only runs in the browser”; it means “allowed to run in the browser, and shipped there as
JavaScript.” The server still renders a Client Component once, to produce a real first
HTML response instead of a blank page while that JavaScript downloads. Request the built
page to see what that first response actually contains:
$ docker run -d --name nextjs-server-components-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
1683a69302bc1d6764d065ff2cca6ac050a929c90a4642f4445868a3f4b5eb1f
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<button type="button">Count: [^/]+/button>'
<button type="button">Count: <!-- -->0</button>
$ docker rm -f nextjs-server-components-2
nextjs-server-components-2
The button really does arrive in the HTML already showing Count: 0 — the server-rendered
first paint. Clicking it would increment that number in a real browser, but curl only
fetches HTML; it never runs the JavaScript that onClick depends on, which is why this
lesson’s evidence for useState working stops at the initial render.
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 a second server-only read, process.platform, next to the Node version in
app/page.tsx, and predict what the next build’s console output and the next curl both show.
Solution
// app/page.tsx
import Counter from "./counter";
export default async function HomePage() {
console.log("HomePage rendering on the server");
return (
<main>
<h1>Home</h1>
<p>Node version on the server: {process.version}</p>
<p>Platform: {process.platform}</p>
<Counter />
</main>
);
}
$ 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"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 121ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.3s
Running TypeScript ...
Finished TypeScript in 4.4s ...
HomePage rendering on the server
Counter rendering
HomePage rendering on the server
Counter rendering
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-server-components-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
762b86cdadb3aab3a405013da245e1fb44e14a224aea773795c68151d9e1ef3e
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<p>Platform: [^/]+/p>'
<p>Platform: <!-- -->linux</p>
$ docker rm -f nextjs-server-components-3
nextjs-server-components-3
linux — the platform of the container running the build, not of the machine typing these
commands. process.platform works the same way process.version did: it is a real Node
global, readable because app/page.tsx never left the server.
2. Add a Label component with no "use client" of its own, and render it from inside
Counter — confirm it needs no directive because it’s only ever imported from a Client
Component.
Solution
// app/label.tsx — no "use client"; only ever imported from a Client Component
export default function Label({ text }: { text: string }) {
return <span>{text}</span>;
}
// app/counter.tsx
"use client";
import { useState } from "react";
import Label from "./label";
export default function Counter() {
console.log("Counter rendering");
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount(count + 1)}>
<Label text="Count:" /> {count}
</button>
);
}
$ 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"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 184ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.8s
Running TypeScript ...
Finished TypeScript in 4.8s ...
HomePage rendering on the server
Counter rendering
HomePage rendering on the server
Counter rendering
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-server-components-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
3dfc82fafb1d549aede7431313af89af3408fc362bd01d3d9b2e37b1e5f7e385
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -oE '<span>Count:</span> [^/]+/button>'
<span>Count:</span> <!-- -->0</button>
$ docker rm -f nextjs-server-components-4
nextjs-server-components-4
The build never asks for a directive on app/label.tsx, and <span>Count:</span> really
does render inside the button. Label is only reachable by following an import chain that
starts inside a "use client" file, so Next.js bundles it right along with Counter — the
boundary is set once, at the file with the directive, not at every file underneath it.
3. Add a Toggle component that uses useState but, like Counter originally did,
forgets "use client". Predict whether the same class of error comes back.
Solution
// app/toggle.tsx — missing "use client", on purpose
import { useState } from "react";
export default function Toggle() {
const [on, setOn] = useState(false);
return (
<button type="button" onClick={() => setOn(!on)}>
{on ? "On" : "Off"}
</button>
);
}
// app/page.tsx
import Counter from "./counter";
import Toggle from "./toggle";
export default async function HomePage() {
console.log("HomePage rendering on the server");
return (
<main>
<h1>Home</h1>
<p>Node version on the server: {process.version}</p>
<p>Platform: {process.platform}</p>
<Counter />
<Toggle />
</main>
);
}
Same Turbopack code frame as before, so the same sed strip applies, and a real terminal
would again show the code-frame lines in color:
$ 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 | sed 's/\x1b\[[0-9;]*m//g'
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 193ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./app/toggle.tsx:2:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
1 | // app/toggle.tsx — missing "use client", on purpose
> 2 | import { useState } from "react";
| ^^^^^^^^
3 |
4 | export default function Toggle() {
5 | const [on, setOn] = useState(false);
Ecmascript file had an error
Import trace:
Server Component:
./app/toggle.tsx
./app/page.tsx
at ignore-listed frames
The exact same error class, now naming app/toggle.tsx:2:10 instead of app/counter.tsx.
The rule applies per file, not once per project — every new Client Component needs its own
"use client" line.
4. Fix Toggle with "use client" and confirm it renders.
Solution
// app/toggle.tsx
"use client";
import { useState } from "react";
export default function Toggle() {
const [on, setOn] = useState(false);
return (
<button type="button" onClick={() => setOn(!on)}>
{on ? "On" : "Off"}
</button>
);
}
$ 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"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 162ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 5.8s
Running TypeScript ...
Finished TypeScript in 6.2s ...
HomePage rendering on the server
Counter rendering
HomePage rendering on the server
Counter rendering
Finalizing page optimization ...
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
$ docker run -d --name nextjs-server-components-5 -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
024979d3bb656e0069436f292d187c0582d94594aa3c97c8f6cc9c8b9b7d1f06
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s http://localhost:3100/ | grep -o '<button type="button">Off</button>'
<button type="button">Off</button>
$ docker rm -f nextjs-server-components-5
nextjs-server-components-5
Off renders exactly as written, with no comment marker splitting it from anything else —
unlike Counter’s button, Toggle’s only child is the single ternary expression, so there
is no static text alongside it for React to separate during serialization.
5. Pass a function from app/page.tsx straight into <Counter> as a prop, and predict
whether the build allows a Server Component to hand a function to a Client Component.
Solution
// app/counter.tsx
"use client";
import { useState } from "react";
import Label from "./label";
export default function Counter({ onIncrement }: { onIncrement: () => void }) {
console.log("Counter rendering");
const [count, setCount] = useState(0);
return (
<button
type="button"
onClick={() => {
setCount(count + 1);
onIncrement();
}}
>
<Label text="Count:" /> {count}
</button>
);
}
// app/page.tsx
import Counter from "./counter";
import Toggle from "./toggle";
export default async function HomePage() {
console.log("HomePage rendering on the server");
return (
<main>
<h1>Home</h1>
<p>Node version on the server: {process.version}</p>
<p>Platform: {process.platform}</p>
<Counter onIncrement={() => console.log("incremented")} />
<Toggle />
</main>
);
}
$ 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
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 201ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.6s
Running TypeScript ...
Finished TypeScript in 4.1s ...
Collecting page data using 4 workers ...
Generating static pages using 4 workers (0/3) ...
HomePage rendering on the server
HomePage rendering on the server
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Event handlers cannot be passed to Client Component props.
{onIncrement: function onIncrement}
^^^^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
at ignore-listed frames {
digest: '2582404780'
}
Export encountered an error on /page: /, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
A different real error from the same boundary, now hit from the other direction: data can
flow from a Server Component into a Client Component’s props, but a function can’t, because
there’s no way to serialize a live server-side closure into the JavaScript bundle sent to the
browser. It surfaces as a build failure only because / gets prerendered as static content —
on a route that opts out of prerendering, the same onIncrement prop would fail at request
time instead, not at build time. The fix is to define the handler inside Counter itself,
exactly as it already was before this task.