Server vs Client Components in Next.js for Beginners
Learn Next.js server vs client components with real code: what runs where, the use client boundary, passing children, and what ships to the browser.
By Matthews WongReviewed on
Key takeaway
In the Next.js App Router every component is a Server Component by default: it renders on the server and ships no JavaScript of its own. Adding use client at the top of a file marks a boundary, and that file and its imports join the browser bundle so they can use state, effects and browser APIs.
You will be able to
- Decide per component whether it runs only on the server or also in the browser.
- Nest server-rendered content inside a client component by passing it as children.
- Keep heavy code out of the first load, and page text in the server HTML.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
One page, two environments: the /play route
The server renders the landing text into HTML, PlayShell crosses the use client boundary and hydrates in the browser, and the 3D engine downloads only after the visitor accepts.
Keys: ← → step · Home / End · K play or pause
The play page is a Server Component. Its landing text stays on the server, its PlayShell gate is a Client Component, and the 3D game is a lazy chunk not yet fetched.
page.tsx runs only on the server. It reads the messages and renders PlayLanding, a Server Component, so none of that component's code is sent to the browser.
Predict the next step Next is PlayShell, marked with use client. What does the browser receive for it? (Only its rendered HTML · Its JavaScript, so it can run)PlayShell starts with use client. That file and everything it imports join the client bundle, and its props must be serializable: here a locale string and a number.
"use client";The browser gets finished HTML with the landing text already inside, plus a payload marking where PlayShell goes. Crawlers and slow phones can read this before any script runs.
Predict the next step The visitor has not accepted the notice yet. Does the 3D engine download now? (Yes, together with the page · No, it waits for the visitor)JavaScript hydrates PlayShell, which shows the notice. Game sits behind next/dynamic with ssr false, so the engine chunk stays unfetched until the visitor accepts.
After the visitor accepts, a client-state branch mounts Game and the browser fetches that lazy chunk. Only now does the engine's weight reach this visitor.
What runs on the server, and what ships to the browser?
In the App Router, layouts and pages are Server Components by default. A Server Component renders ahead of time, in an environment separate from the browser, either once at build time or for each request. The browser does not receive its code. It receives only the rendered output, which is why server code can query a database or use a secret without exposing either.
Read more (2)Show less
Use a Client Component when you need state and event handlers, lifecycle logic such as useEffect, browser-only APIs such as localStorage or window, or custom hooks. A Server Component cannot use useState, because it is never sent to the browser to hold state. That one rule explains most of the errors beginners hit.
A common misreading: a Client Component is not client-only. On the first load, Next.js uses the Client Components and the Server Component payload to prerender HTML, then JavaScript hydrates the Client Components to attach event handlers. The user sees real HTML first. Later navigations fetch just the payload, and Client Components render in the browser.
What does the use client directive actually do?
Write use client at the very top of a file, before the imports. It declares a boundary between the server and client module graphs. Every module that file imports, and every component it renders directly, goes into the client bundle. You do not repeat the directive in each child file, and you only need it on files whose components a Server Component renders directly.
Read more (2)Show less
Props that cross the boundary must be serializable, so React can send them from server to browser. A string, a number, an object of plain values: fine. A function: not allowed. This is why this site's PlayShell takes a locale string and a byte count, and nothing like an onClose callback.
Because the directive pulls in a whole subtree, put it as deep as you can. The Next.js docs make the point with a layout that is mostly a logo and links but contains one search bar: only the search bar becomes a Client Component, and the rest stays on the server.
How do you mix them? Pass server content as children
You can pass a Server Component as a prop, most often children, to a Client Component. The Client Component only provides a slot, such as a modal that toggles visibility with state. The Server Component is rendered on the server ahead of time, and the payload carries its rendered result plus a placeholder where the Client Component goes. The passed component is not imported into the client module graph, so its code does not ship.
Read more (2)Show less
The same shape handles providers. React context is not supported in Server Components, so a provider is a small Client Component that takes children, and the server layout renders it. Render providers as deep as possible: wrap the children, not the whole html element, so Next.js can still optimize the static parts.
This site's SiteChrome is a worked example. It must read the pathname, so it is a Client Component. But the header, the footer and the floating helpers are passed in as props from the server layout, so SiteChrome only decides whether to show them.
How does this site keep heavy code and page text apart?
Heavy code goes behind next/dynamic. The docs describe it as a composite of React.lazy and Suspense, and lazy loading applies to Client Components. The ssr false option works only inside a Client Component. That is why the 3D engine is imported in PlayShell, a Client Component, and only mounts after the visitor accepts a notice. A build script enforces it: three may be imported only under the game folders, and no route may reach the engine through a static import.
Read more (2)Show less
Page text goes the other way: it renders inline, never behind a loading state. A loading.js file wraps the page in a Suspense boundary. React then streams the real content in a hidden element and swaps it in with an inline script, so a reader that does not run scripts sees the fallback in main and the article only inside the hidden element. The Next.js docs say streaming does not hurt SEO for crawlers that render the page, but this site chooses the stricter rule.
So this site has no route loading file, and no loading option on a dynamic import that carries text. The one loading card you will find, GameLoading, shows only inside the game overlay after the visitor accepts, which is a client-state branch, so it is never in the server HTML.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
// The engine chunk (three, fiber, drei, bvh) only ever loads here, and only
// after the visitor accepts — the whole point of the notice gate.
const Game = dynamic(() => import("@/app/components/game/Game"), {
ssr: false,
loading: () => <GameLoading />,
});The lazy boundary. PlayShell is a Client Component, so ssr: false is legal here. The comment names the intent: the engine chunk loads only after the visitor accepts. The loading card is safe because Game mounts only in a client-state branch, after acceptance.
{/* The landing: stills, premise, steps, facts. No client JavaScript. */}
<PlayLanding
locale={pageLocale}
firstLoadBytes={manifest.firstLoadBytes}
/>
{/* The gate. Before acceptance this is the notice card; after it, the
fixed overlay that is the game. The hero button jumps here. */}
<div className="mt-12">
<NextIntlClientProvider messages={stripToEssentialBlogPosts(messages)}>
<PlayShell
locale={pageLocale}
firstLoadBytes={manifest.firstLoadBytes}
/>
</NextIntlClientProvider>
</div>The server page. PlayLanding is a Server Component with no client JavaScript, so its text is in the HTML. PlayShell is the gate, the only Client Component here. It sits inside a provider that carries just the messages the game needs.
interface SiteChromeProps {
header: ReactNode;
footer: ReactNode;
/** Floating helpers: the chatbot, the selection explainer. */
extras: ReactNode;
children: ReactNode;
}The slot pattern. SiteChrome is a Client Component, yet its props are ReactNode values: the server layout renders the header and footer and hands them over. SiteChrome decides only whether to show them.
{/* SiteChrome drops the header, footer and floating helpers on
the immersive /play route; every other route renders them as
before. */}
<SiteChrome
header={<Header />}The other half of the slot. The server layout renders Header and passes it in as a prop. Header is itself a Client Component, so passing it this way is allowed. The comment records why: /play is immersive and drops the chrome.
Mistakes that cost real time
State or an event handler in a Server Component
Importing useState or writing onClick in a file without use client fails, because a Server Component is never sent to the browser. Move only the interactive piece into its own small file with use client, and keep the page a Server Component.
A function passed from server to client
Props that cross the use client boundary must be serializable, and a function is not. Pass plain data, and define the handler inside the Client Component. The docs show an onClick prop flagged as not serializable.
Server-only code imported into a client file
A module is shared between both environments, so a data helper that reads a secret can be imported into a Client Component by accident. Only variables prefixed NEXT_PUBLIC_ reach the browser, so the secret turns into an empty string and the call fails quietly. Import the server-only package at the top of the helper to get a build error instead.
ssr: false in a Server Component
next/dynamic with ssr false is not allowed in a Server Component, and Next.js shows an error. Move the dynamic import into a Client Component, as PlayShell does. The docs add that this also keeps the client code splitting working.
A loading skeleton hides the page text
A loading.js or a loading option on a dynamic import makes React ship the page content after the footer inside a hidden element, with a skeleton in main. Anything that does not run scripts reads the skeleton. This site found exactly that on every page, and fixed it on 2026-10-13 by removing them.
| Server Component | Client Component | |
|---|---|---|
| How you get one | The default in app/ | use client at the top of the file |
| State, effects, browser APIs | Not available | Available |
| Secrets and direct data access | Safe to use | Never put secrets here |
| JavaScript sent to the browser | None of its own | Its code and its imports |
| Where it renders | On the server only | Server for first HTML, then browser |
Do it on your own machine
You need Node and a fresh Next.js app. You will build one Server Component page with one small Client Component, then look at what the browser actually receives.
Create the app and start it. Accept the defaults, including the App Router.
npx create-next-app@latest lab cd lab npm run devAdd a Client Component that owns the only state on the page.
// app/like-button.tsx "use client"; import { useState } from "react"; export default function LikeButton({ start }: { start: number }) { const [likes, setLikes] = useState(start); return <button onClick={() => setLikes(likes + 1)}>{likes} likes</button>; }Make the page a Server Component that renders text and passes a plain number to the button.
// app/page.tsx import LikeButton from "./like-button"; export default function Page() { return ( <main> <h1>Server text, rendered once</h1> <LikeButton start={3} /> </main> ); }View the page source, not the inspector. The heading and the button text are already in the HTML, even though the button is a Client Component.
curl -s http://localhost:3000 | grep -o 'Server text, rendered once'Break it on purpose: pass a function as a prop from page.tsx, such as onDone={() => {}}. Read the error, then remove it.
Wrap the button in a Client Component slot that takes children, then pass a second Server Component through it.
// app/panel.tsx "use client"; import { useState, type ReactNode } from "react"; export default function Panel({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); return ( <div> <button onClick={() => setOpen(!open)}>Toggle</button> {open && children} </div> ); }
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Does use client mean a component never runs on the server?Show the answer
Answer:No. On the first load, Client Components are prerendered to HTML on the server together with the Server Component payload, then hydrated in the browser. Use client changes what is shipped as JavaScript, not whether the first HTML exists.
2.Why do pages other than /play not download the 3D engine?Show the answer
Answer:The engine sits behind next/dynamic in PlayShell and mounts only after acceptance. A prebuild script also fails the build if three is imported outside the game folders or any route reaches the engine through a static import.
3.A teammate adds a loading skeleton to a text-heavy page. What do you tell them?Show the answer
Answer:Render the text inline. A Suspense fallback puts the skeleton in main and the real text in a hidden element, which a reader without scripts never sees. Navigation feedback comes from the route progress bar instead.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.