Skip to main content
Next.js · lesson 2 of 11
Next.js beginner 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

nextjs server-components client-components

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.

Frequently Asked Questions

Is a component a Server Component or a Client Component by default in the Next.js App Router?
Server Component by default. Every file under app/ renders on the server unless it (or an ancestor file it's only reachable through) has a "use client" directive before any import or other code — a comment may come first, but nothing else can. Nothing has to be written to opt into the server behavior, only to opt out of it.
Why does a Server Component importing useState break the build instead of just warning?
React's useState hook depends on the browser re-rendering the UI when state changes, which only Client Components do. A plain Server Component never ships any JavaScript to the browser at all, so there is no re-render for useState to attach to. Next.js's build rejects the import outright rather than shipping an app that would only fail at runtime, once a browser actually tried to run code that depends on a re-render loop that was never shipped to it.
Does adding "use client" to a component mean it only runs in the browser?
No. A Client Component still renders once on the server to produce the page's first HTML, so the browser has something to show before any JavaScript downloads, and then runs again in the browser once the page hydrates. "use client" marks a component as allowed to run in both places, not as server-free.
If a Client Component imports another component with no 'use client' of its own, does that import also need the directive?
No. Once a file has "use client" at its top, anything it imports is bundled into the same client boundary along with it. A plain component with no directive of its own, imported only from inside a Client Component, never needs one of its own.
Why does passing a function as a prop from a Server Component to a Client Component fail the build?
A Server Component's props to a Client Component have to cross from the server's render into the JavaScript bundle sent to the browser, and a function can't be serialized across that boundary — there is no way to ship a live server-side closure to the browser. The error comes from React's own server-rendering serializer (bundled inside Next.js, not a Next.js-specific check), and its exact wording depends on the prop's name: a prop starting with on (like this lesson's onIncrement) is read as an event handler and gets "Event handlers cannot be passed to Client Component props."; any other function prop (fmt, say) gets "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'." Either way, it only shows up as a build failure here because / gets prerendered — on a route that opts out of prerendering, the same prop fails at request time instead, caught and logged to the server's console rather than stopping the build.