#Server Components by default
AddedIn the App Router, every component is a Server Component unless something opts it into the client. A Server Component runs only on the server, at build time or per request. It can be async, read a database, use Node.js APIs and secrets, and import big libraries, and none of that code is sent to the browser. What the browser gets is its output: the rendered elements, as part of the RSC payload, plus the HTML made from it. Client Components, the ones in or below a 'use client' file, are the components you know from plain React: they're rendered to HTML on the server too, and then their code ships to the browser and hydrates.
A mental model: think of what the browser receives for each kind of component. For a Server Component it gets a printed page: finished output, no recipe. For a Client Component it gets a photo and a flat-pack kit: the server-rendered HTML (the photo, so the user sees something at once) and the JavaScript to assemble the real, working thing (hydration). Printed pages are cheap to send and can't change by themselves; kits cost download and assembly time but can react to the user. A good App Router page is mostly printed pages with a few small kits in it.
The table that settles most "where does this run" questions:
| Renders on the server | Runs in the browser | Its code is in the client bundle | |
|---|---|---|---|
| Server Component | Yes (build time or per request) | No | No |
| Client Component | Yes, to HTML on the first load | Yes (hydration, then every re-render) | Yes |
"Client" doesn't mean "client-only": a Client Component also renders on the server. "Server" does mean "server-only": a Server Component's code never reaches the browser. This app has an async Server Component that reads "the database" and a small Client Component inside it:
import { getProducts } from "@/lib/products";
import { formatPrice } from "@/lib/format";
import { LikeButton } from "./like-button";
export async function ProductList() {
const products = await getProducts();
console.log(`ProductList rendered, window is ${typeof window}`);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}: {formatPrice(p.priceCents)} <LikeButton initialLikes={p.likes} />
</li>
))}
<li>Rendered with Node.js {process.version}</li>
</ul>
);
}page.tsx,product-list.tsx,lib/products.tsandlib/format.tshave no directive, so they're Server Components and server modules by default.like-button.tsxstarts with"use client", soLikeButtonis the one Client Component.ProductListis anasyncfunction component: itawaitsgetProducts(), which uses Node'snode:timers/promisesto stand in for a 300 ms database query. Only Server Components can be async. It also readsprocess.version, a Node.js value that doesn't exist in a browser.- The await is uncached I/O, so with Cache Components (on in new Next.js 16 apps) the list can't be part of the build-time HTML. The
<Suspense>around it lets Next.js prerender everything else (the<h1>and the "Loading products…" fallback) as a static shell, and stream the list per request. That's why the route below is ◐, Partial Prerender (chapter 05 covers it; cachinggetProductswith'use cache'would make it fully static). - For each product,
ProductListrenders a<LikeButton initialLikes={…} />. On the server this doesn't runLikeButton's code inside the Server Component render; it records a reference to a client module plus the props{ initialLikes: 12 }. OnlyinitialLikescrosses to the browser:costCentsis never rendered or passed, so it never leaves the server. - On the first load, Next.js also renders
LikeButtonto HTML on the server (so the button shows "♥ 12" before any JavaScript runs), then the browser downloadslike-button.tsx's code, runs it again and attachesonClick: hydration. Clicking updates its state in the browser; nothing goes back to the server. - What this demonstrates: the data access, the formatting code and the price data stayed on the server. The only component code the browser receives is the button.
The rest of the shop app
The data module, the price formatter, the page with its Suspense boundary, the Client Component, and the route table the build printed:
import { setTimeout as sleep } from "node:timers/promises";
export type Product = { id: string; name: string; priceCents: number; costCents: number; likes: number };
const rows: Product[] = [
{ id: "p1", name: "Trail running shoes", priceCents: 14999, costCents: 6200, likes: 12 },
{ id: "p2", name: "Merino base layer", priceCents: 8999, costCents: 3100, likes: 7 },
];
export async function getProducts(): Promise<Product[]> {
await sleep(300); // stands in for a database query
return rows;
}const money = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
export function formatPrice(cents: number) {
return money.format(cents / 100);
}import { Suspense } from "react";
import { ProductList } from "./product-list";
export default function Page() {
return (
<main>
<h1>Shop</h1>
<Suspense fallback={<p>Loading products…</p>}>
<ProductList />
</Suspense>
</main>
);
}"use client";
import { useState } from "react";
export function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
console.log(`LikeButton rendered, window is ${typeof window}`);
return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}Route (app)
┌ ◐ /
└ ○ /_not-found
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentI ran it with next build && next start -p 4503, requested / once with curl, and opened it once in headless Chrome. The server terminal printed, for each request:
ProductList rendered, window is undefined
LikeButton rendered, window is undefined
LikeButton rendered, window is undefined
ProductList rendered, window is undefinedand Chrome's console, for the page load:
log: LikeButton rendered, window is object
log: LikeButton rendered, window is objectLikeButtonlogs in both places: twice in the terminal (one per product, while Next.js renders the HTML) and twice in the browser (hydration). That's the "Client Components render on the server too" row of the table, observed.windowisundefinedin the first and an object in the second, which is why browser-only code in a Client Component belongs in effects and event handlers, not in the render body.ProductListlogs only in the terminal. Its code isn't in the browser, so it can't log there.ProductListlogged twice per request in this production run (onecurl), whileLikeButton's server log appeared once per product. The secondProductListrender is Partial Prefetching (partialPrefetching: true, the template's setting, added in Next.js 16.3): after the normal request render, Next.js 16.4.3 runs an RSC-only "runtime prerender" of the page and embeds its result in the payload so the client can cache it (chapter 04 has the details). I confirmed it by logging React's work-unit type insideProductList(request, thenprerender-runtime) and by rebuilding withpartialPrefetching: false: oneProductListlog per request. The extra pass renders no HTML, which is whyLikeButton's SSR log doesn't double. The lesson: render must be free of side effects, because you don't control how many times Next.js renders a Server Component.- After hydration I clicked the first button: the browser logged one more
LikeButton rendered, the text became "♥ 13", and the terminal logged nothing. Client state changes re-render Client Components in the browser only.
What reached the browser
The build output is where the "zero JavaScript" claim can be checked. I searched every file in .next/static (everything the browser can download) for strings that only appear in each module:
$ for s in costCents "Trail running" "en-CA" "ProductList rendered" "LikeButton rendered" "Rendered with Node"; do …grep -rl "$s" .next/static | wc -l…; done
costCents static JS files: 0
Trail running static JS files: 0
en-CA static JS files: 0
ProductList rendered static JS files: 0
LikeButton rendered static JS files: 1
Rendered with Node static JS files: 0The product names do reach the browser, as data inside the HTML response. Next to the <li> elements, the HTML carries the same tree in a script tag, and this row (from curl -s localhost:4503/, unescaped for reading) is ProductList's output:
1a:"/_next/static/chunks/1vc0wyi3-23y0.js"
1b:I[15226,["$2","$1a"],"LikeButton"]
8:["$","ul",null,{"children":[[["$","li","p1",{"children":["Trail running shoes",": ","$$149.99"," ",["$","$L1b",null,{"initialLikes":12}]]}],["$","li","p2",{"children":["Merino base layer",": ","$$89.99"," ",["$","$L1b",null,{"initialLikes":7}]]}]],["$","li",null,{"children":["Rendered with Node.js ","v26.7.0"]}]]}]- Row
1bis anI(import) row: "the client module with id15226, in these chunks, exportLikeButton". That's the client reference. Row1ais the chunk's URL. - Row
8isProductList's rendered output: plain elements as["$", type, key, props]arrays. The<li>keysp1andp2came fromkey={p.id}. - Inside each
<li>,["$","$L1b",null,{"initialLikes":12}]is theLikeButtonelement: type "the thing row1bpoints to", props{ initialLikes: 12 }. That's all the browser learns about the server's product row. "$$149.99"is the formatted price. Strings that start with$are escaped as$$, because$marks references in this format.formatPriceran on the server; only its result is here.- There's no
costCentsand no code forProductList,getProductsorformatPrice. How the RSC payload works reads the rest of the format.
A Server Component runs once per render, on the server, with no browser and no re-render loop, so it can't hold state, run effects or handle events. Next.js enforces this at build time: in a Server Component, react resolves to React's server build, which doesn't export the client hooks. I listed the exports of the server build bundled with Next.js 16.4.3 (next/dist/compiled/react/cjs/react.react-server.production.js):
| Available in Server Components | Not available (Client Components only) |
|---|---|
use, cache, cacheSignal, useId, useMemo, useCallback, useDebugValue | useState, useReducer, useEffect, useLayoutEffect, useRef, useContext, createContext |
Suspense, Activity, ViewTransition, Fragment, memo, lazy, forwardRef, Children, cloneElement | useTransition, useActionState, useOptimistic, useSyncExternalStore, class components (Component) |
useMemo and useCallback exist only so shared code can call them; on the server they just run the function. Using a client hook fails the build. This page:
import { useState } from "react";
export default function Page() {
const [count, setCount] = useState(0);
return <p>{count}</p>;
}fails next build with (paths shortened to the project, colours removed):
Error: Turbopack build failed with 1 error:
./src/app/page.tsx:1: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 | import { useState } from "react";
| ^^^^^^^^It fails at the import, before anything renders: Turbopack knows which graph the module is in and has a list of client-only React APIs. I tried each hook in the right-hand column in its own page: useEffect, useRef, useReducer, useLayoutEffect, useTransition, useActionState, useOptimistic, useSyncExternalStore and createContext all failed with this same message. useContext was the exception: it got past the compiler and failed while prerendering, with TypeError: (0 , c.useContext) is not a function (Context, providers and third-party libraries has the details and the fix).
Event handlers fail one step later. This compiles, because onClick is just a prop in JSX:
export default function Page() {
return <button onClick={() => console.log("hi")}>Say hi</button>;
}but the page is static, so next build prerenders it, and React refuses to put a function into the output:
Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: Event handlers cannot be passed to Client Component props.
{onClick: function onClick, children: ...}
^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.- The page renders on the server, and its output has to be serialised into the RSC payload. A
<button>with anonClickfunction can't be: there's no way to send a function's code and closure to the browser as data. - React's message says "Client Component props" even though
<button>is a DOM element: from the RSC serialiser's point of view, any element that ends up in the browser is rendered by the client, and its props must cross the wire. - Because the route is static, the error appears during
next build. In a dynamic part of a page, the build passes and the same mistake fails per request. I moved the button into an async component that awaitsconnection()inside<Suspense>: the build printed ◐,curlgot200 OK(the static shell had already been sent), the terminal logged the same "Event handlers cannot be passed" error with adigest, and Chrome replaced the page with "This page couldn’t load … ERROR 4236530517", the digest. A passing build doesn't prove dynamic parts are safe. - The fix is to move the button (and its handler) into a
'use client'component, or use built-in HTML behaviour that needs no JavaScript: a<form action={serverAction}>, a<details>element, a link.
Browser globals fail too: window, document and localStorage don't exist in a Server Component (the log above printed window is undefined), so touching them throws during the server render.
When to reach for each
- Server Component (the default): fetching data, reading cookies and headers, using secrets, rendering Markdown or syntax highlighting with heavy libraries, layout and static content. Anything that doesn't need to respond to the user in the browser.
- Client Component: state (
useState,useReducer), effects, event handlers, browser APIs (localStorage,IntersectionObserver, geolocation), custom hooks that use those, and libraries built on them. - Neither needed: a lot of "interactivity" is built into HTML: a
<form>posting to a Server Action,<details>/<summary>,<video controls>, links. Next.js's boundary guide makes the same point: a button or form doesn't need a Client Component when the browser provides the behaviour.
In the rsc-default app, a user opens / in a fresh tab, waits for the page, and clicks the "♥ 7" button once. Counting only the lines this app logs, what does the server terminal show for that visit, and what does the browser console show?
Show answer
Terminal: ProductList rendered twice (once with partialPrefetching: false) and LikeButton rendered, window is undefined twice. Browser: LikeButton rendered, window is object three times.
- The request reaches the server. The static shell is already built; the
ProductListhole renders now:ProductListawaits the products and logs on the server, then logs again in Partial Prefetching's runtime prerender (see above). - Next.js renders the HTML for the hole, which includes the two
LikeButtons: two server logs withwindow is undefined. - The browser parses the HTML (the buttons already show "♥ 12" and "♥ 7"), downloads
LikeButton's chunk and hydrates: two browser logs withwindow is object.ProductListnever logs in the browser because its code isn't there. - The click calls
setLikes(8)on the second button, which re-renders only thatLikeButton, in the browser: one more browser log, and nothing on the server.