Next.js App Router
Server Components and Client Components: What Runs Where
Server by default, what use client really means, the boundary, passing props across it, and the mistakes that leak secrets or bloat the bundle.
On this page
- Words used in this lesson
- Two places to run code
- Server is the default
- Why bother: the bundle test
- What 'use client' really does
- What the browser actually receives
- The rules the compiler enforces
- What can cross the boundary
- The leak: your whole object is in the page
- Passing data the other way: streaming with use
- Context providers and third-party components
- A simple way to decide
- Common mistakes
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: In the App Router every component is a Server Component unless you say otherwise. It runs only on the server, so its code never reaches the browser and it can read a database or a secret directly. You add 'use client' at the top of a file only when you need state, effects, event handlers or browser APIs. That line marks a boundary: the file and everything it imports now ship to the browser. Most bugs and most leaks in Next.js come from not knowing where this boundary is.
In plain React, every component runs in the browser. In the App Router, components can run in two places, and you choose which. That one choice decides how big your JavaScript download is, where your secrets can live, and how you fetch data. It is the biggest idea in modern Next.js, and also the one beginners find strangest.
This is lesson 1 of ten. In lesson 0 you learned the folders, layouts and special files. Here we learn what runs where. The next lesson, Data Fetching and Caching in the App Router, builds on this: Server Components are where you fetch data. If React itself is still new to you, Thinking in React comes first, and Patterns, Server Components and Testing React gives the React-side view of the same idea.
What I ran / checked. Everything ran on Linux with Node.js v22.22.0, Next.js 16.3.5 (the version this repo uses) and Turbopack, the default bundler, with React 19.3.0 and TypeScript 7.0.2. I made a throwaway app in /tmp, ran next build, next start and next dev, used curl to read the HTML, and used Playwright 1.56.1 with Chromium to click buttons with JavaScript on and off. I used marked 18.1.0 as a "big library" for the bundle test. Sources I read (in my own words below): the guides inside the installed package at node_modules/next/dist/docs/01-app/, namely "Server and Client Components", "The Server and Client Boundary", "Fetching data", "Data security", the use client page and the taint config page. This repo's AGENTS.md tells you to read those first, because this Next.js is newer than many tutorials. Not verified: I did not open nextjs.org or react.dev. I did not test the experimental taint option. Sizes and timings are from one machine and will vary a little.
Words used in this lesson
- Server Component is a React component that runs only on the server. Its code is never sent to the browser. It is the default in the
appfolder. - Client Component is a component in a file that starts with
'use client'. It is rendered to HTML on the server, and its code is also sent to the browser so it can run there. - Boundary is the line, created by
'use client', between the server part of your code and the client part. - Module graph is the tree of files that import each other. Everything a Client Component file imports joins the client graph.
- Bundle is the JavaScript file(s) the browser downloads. A smaller bundle means a faster page.
- Hydration is React attaching event handlers to HTML that the server already made, so the buttons start to work.
- RSC Payload is a compact description of the server-rendered component tree. It holds the output of Server Components, placeholders for Client Components, and the props passed to them. (RSC means React Server Components.)
- Serializable means a value that can be turned into text and sent over the network. Props crossing the boundary must be serializable.
server-onlyis a tiny package. If you import it in a file, the build fails when that file is pulled into the client.- DTO (data transfer object) is a small plain object that holds only the fields a screen needs.
- Environment poisoning means server-only code (or a secret) being pulled into the client by an import.
Two places to run code
Think of your app as two computers working together:
- The server: your machine (or a cloud machine). It has the database, the secret keys and the file system. Nobody outside can look inside it.
- The browser: the user's machine. It can show things, listen to clicks and remember state. Anyone can open DevTools and read everything it receives.
A Server Component runs on computer 1 only. A Client Component runs on computer 2, and also once on computer 1 (to make the first HTML). The official guide has a short table that I find the clearest:
| Runs on the server | Runs in the browser | |
|---|---|---|
| Server Component | Yes | No |
| Client Component | Yes (to make the first HTML) | Yes |
The name "Client Component" is misleading in one way. It does not mean "client only". It means "also runs in the client". I will prove that below with a log line.
What each kind can do
Use a Server Component when you want to:
- read a database or call an internal API directly,
- use an API key or token without exposing it,
- send less JavaScript to the browser,
- show content as early as possible.
Use a Client Component when you need:
- state (
useState,useReducer) and event handlers (onClick,onChange), - effects (
useEffect) and other lifecycle logic, - browser-only APIs like
window,localStorage, ornavigator.geolocation, - custom hooks that use any of the above.
That list is short on purpose. In a typical page, most of the screen is text, images and lists. Only a few pieces (a like button, a search box, a menu) really need the browser. Those few pieces are the Client Components.
Server is the default
You do not write anything to get a Server Component. Every page.tsx and layout.tsx in the app folder is one. And since a Server Component runs on the server, it can be async and await data right in the component:
// app/page.tsx (a Server Component, by default)import Counter from './ui/counter'export default function Page() { console.log('[server] Page rendered') return ( <main> <h1>Home</h1> <Counter start={5} /> </main> )}// app/ui/counter.tsx'use client'import { useState } from 'react'export default function Counter({ start }: { start: number }) { console.log('[counter] rendered, typeof window =', typeof window) const [n, setN] = useState(start) return <button id="c" onClick={() => setN(n + 1)}>count: {n}</button>}I ran next build on this small app. The terminal printed this while it made the static pages:
Generating static pages using 3 workers (0/3) ...[server] Page rendered[counter] rendered, typeof window = undefinedLook at the second line. The Counter has 'use client', and still it ran on the server. typeof window is undefined there, because there is no browser. This is the first-HTML step: Next.js renders Client Components to HTML too, so the page shows content before any JavaScript arrives.
Now the same page in a real browser. I used Playwright with Chromium, opened the page, clicked the button twice and read the text. I did it once with JavaScript turned off and once with it on:
JS off: before click="count: 5" after 2 clicks="count: 5"JS on : before click="count: 5" after 2 clicks="count: 7" browser console: [counter] rendered, typeof window = object (3 times)What this shows:
- With JavaScript off, the page still shows
count: 5. That text came from the server HTML. The button does nothing, because no event handler is attached. - With JavaScript on, the button works (
count: 7). The browser console shows the log line three times: once when React hydrated the HTML, and once for each click. Nowtypeof windowisobject, because this is the browser.
So a Client Component renders in both places. A Server Component renders only on the server. That is the whole difference, and it explains everything else in this lesson.
The official docs also say that on later navigations (when you click a <Link>), Client Components render only in the browser, without server HTML. The server sends just the RSC payload, which we look at soon.
Why bother: the bundle test
The main reason to prefer Server Components is that their code does not ship. Let me prove it with a real library. I picked marked (a markdown to HTML converter), because it is a typical "big" dependency. First I used it in the Server Component page:
// app/page.tsx (Server Component uses marked)import { marked } from 'marked'import Counter from './ui/counter'export default function Page() { const html = marked.parse('# Hello **world**') as string return ( <main> <h1>Home</h1> <div dangerouslySetInnerHTML={{ __html: html }} /> <Counter start={5} /> </main> )}After next build I measured all the JavaScript files in .next/static/chunks, which are the files sent to the browser, and searched them for marked:
marked NOT in client chunksclient JS: raw=566088 bytes, gzip=170325 bytesThen I moved the exact same marked.parse(...) call into a Client Component (a file with 'use client') and built again:
client JS: raw=610927 bytes, gzip=183783 bytes (the markdown library is now in a chunk)That is about 45 KB more raw and 13 KB more gzipped, for one small library on one small page. The visible page is the same in both cases. The only change is where the code runs. Imagine a real app with a date library, a syntax highlighter and a chart library. Keeping the heavy, non-interactive work on the server is the cheapest speed-up you can get.
The 566 KB base is the React and Next.js runtime and does not depend on your code. Do not compare it with the 45 KB: compare the two builds. The only difference between them is where one library ran.
What 'use client' really does
Many beginners think 'use client' marks one component. It does more than that. It marks a boundary in the module graph. The docs say that once a file has 'use client', everything it imports, and the components it renders directly, join the client bundle.
So you do not need the directive in every file. You put it at the edge: in the file where the browser part starts. Everything below it in imports is client code too.
I tested the rule with one component called Cart, which has no directive (so it is a Server Component) and a string Cart: 3 items. Then I used it in two ways and searched the client chunks for that string.
Way A. A server page passes <Cart /> as children to a Client Component Modal:
// app/slot/page.tsx (Server Component)import Modal from '../ui/modal'import Cart from '../ui/cart'export default function Page() { return <Modal><Cart /></Modal> }// app/ui/modal.tsx'use client'import { useState } from 'react'export default function Modal({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(true) return <div><button onClick={() => setOpen(!open)}>toggle</button>{open && <div id="modal">{children}</div>}</div>}Way B. A Client Component file imports Cart itself:
// app/ui/modal2.tsx'use client'import { useState } from 'react'import Cart from './cart'export default function Modal2() { const [open, setOpen] = useState(true) return <div><button onClick={() => setOpen(!open)}>toggle</button>{open && <Cart />}</div>}The result:
Way A (passed as children): "Cart: 3 items" is NOT in the client chunksWay B (imported by a client file): "Cart: 3 items" IS in a client chunkThis is the most useful pattern in this lesson. Server Components passed as children (or any prop) to a Client Component stay on the server. The server renders <Cart /> first, and sends its output. The Client Component only gets a slot to put it in. But if a Client Component file imports a component, that component is pulled into the client graph, whatever it was before.
The picture to keep in mind:
Server Component (page)├─ Modal (Client Component) <- the boundary│ └─ children = <Cart /> <- rendered on the server, passed in as output└─ Footer (Server Component)So you can put a Client Component at the top of a section (a modal, a tab bar, a sidebar toggle) and still keep the content inside it as Server Components. This is called interleaving.
Keep the boundary small
The docs give a simple rule for bundle size: put 'use client' on the specific interactive piece, not on a large part of the layout. Their example is a layout with a logo, links and a search box. Only the search box needs the browser. So only Search is a Client Component, and the logo and links stay on the server.
A habit I follow: if a page needs one button with state, I make a tiny LikeButton.tsx file with 'use client', not the whole page. I never add 'use client' to page.tsx just because "something in it needs a click".
Do not put 'use client' at the top of a page "to make an error go away". It does make the error go away, because Client Components can use hooks. But now the whole page, and everything it imports, is in the bundle, and you lost the ability to read the database directly in that page. Move the interactive part into its own small file instead.
What the browser actually receives
On a first visit, the browser gets three things:
- HTML, which shows a fast, non-interactive page.
- The RSC payload, which is a description of the server-rendered tree. React uses it to match the server part with the client part.
- JavaScript for the Client Components, which React uses to hydrate them (attach the click handlers).
I can show you the payload. After a build, Next.js keeps the payload of a static page as a file. This is what .next/server/app/index.segments/__PAGE__.segment.rsc held for my page (shortened):
2:I[10202,["/_next/static/chunks/3fntmmi971322.js","/_next/static/chunks/319ey4lox0a7c.js"],"default"]...0:{"buildId":"...","data":[{"rsc":["$","$1","c",{"children":[["$","main",null,{"children": [["$","h1",null,{"children":"Home"}], ["$","div",null,{"dangerouslySetInnerHTML":{"__html":"<h1>Hello <strong>world</strong></h1>\n"}}], ["$","$L2",null,{"start":5}]]}], ...Read it slowly:
- The
<h1>Home</h1>and the rendered markdown are plain output. They are the result of the Server Component. Themarkedlibrary is not there, only its output. ["$","$L2",null,{"start":5}]is the placeholder for the Client Component: "put component 2 here, with the propstartset to 5".- The line
2:I[10202, [...chunk files...], "default"]tells the browser which JavaScript files to load for component 2.
That is exactly what the docs describe: the payload has the rendered Server Components, placeholders for Client Components with links to their JavaScript, and the props passed across. The same HTML page also carries this payload inside a few <script> tags (self.__next_f.push(...)), so React can start without another request.
When you later click a <Link>, the browser fetches the payload for the new page, and React updates the screen. It does not need new HTML. That is how navigation stays fast, and how the layouts we met in lesson 0 keep their state.
Everything in that payload is visible to the user. The next section shows what that means in practice.
The rules the compiler enforces
Next.js stops you from mixing things up. I tried each of the common mistakes on purpose, and these are the real messages.
1. A hook in a Server Component. I wrote import { useState } from 'react' in a page with no directive. next build stopped:
./app/bad1/page.tsx:1:10Error: 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.2. An event handler in a Server Component. I wrote <button onClick={() => console.log('hi')}>. The build failed while making the page:
Error occurred prerendering page "/bad2".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.3. window while rendering a Client Component. Remember that Client Components also run on the server. I wrote <p>Width: {window.innerWidth}</p> in one:
Error occurred prerendering page "/win".ReferenceError: window is not defined at <unknown> (app/ui/width.tsx:3:21)The fix is to read browser values after the first render, in an effect, and to show a safe default first:
'use client'import { useEffect, useState } from 'react'export default function Width() { const [w, setW] = useState<number | null>(null) useEffect(() => { setW(window.innerWidth) }, []) // runs only in the browser return <p id="w">Width: {w ?? 'unknown yet'}</p>}The server HTML I got from curl was <p id="w">Width: <!-- -->unknown yet</p>. Then the browser fills in the real number. This also keeps the server and browser output equal at hydration time, which React needs.
Hydration mismatch. If the first browser render gives different HTML from the server, React warns you or has to redo work. Typical causes: Date.now(), Math.random(), or window checks directly in the render. Keep the first render the same, and put the changing value in an effect.
4. A function as a prop. This one needs a section of its own.
What can cross the boundary
When a Server Component renders a Client Component, the props are sent in the payload. So they must be serializable: values that can be turned into text. I tested a few types by passing them as a prop from a server page, using next dev:
| Value passed from server to client | Result |
|---|---|
| a string, number, boolean, plain object, array | works |
Date | works (the client got a real Date, I printed 1970-01-01T00:00:00.000Z) |
Map and Set | works (the client got a Map with size 1) |
bigint | works |
a function () => 1 | 500 error |
| an instance of your own class | 500 error |
The two errors were:
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". <... label="fn" value={function value}> ^^^^^^^^^^^^^^^^Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components.Classes or null prototypes are not supported.The first error has a clue: functions can cross the boundary only when they are Server Actions (marked with 'use server'). That is the topic of lesson 3. For now: pass plain data, not functions, not class objects. If you have a class, pass its fields.
The leak: your whole object is in the page
Here is the mistake that causes real incidents. A page loads a user from the database, and passes the whole record to a small Client Component that only needs the name.
// lib/users.tsexport type User = { id: number; name: string; email: string; passwordHash: string; apiKey: string }export async function getUser(id: number): Promise<User> { return { id, name: 'Asha', email: 'asha@example.com', passwordHash: '$2b$10$abcdefSECRETHASH', apiKey: 'sk_live_TOPSECRET' }}// app/ui/profile-card.tsx'use client'import { useState } from 'react'export default function ProfileCard({ user }: { user: { name: string } }) { const [open, setOpen] = useState(false) return ( <div> <button onClick={() => setOpen(!open)}>Toggle</button> {open && <p>Hello {user.name}</p>} </div> )}// app/leak/page.tsx (the mistake)import { getUser } from '@/lib/users'import ProfileCard from '../ui/profile-card'export default async function Page() { const user = await getUser(1) return <ProfileCard user={user as any} /> // "it only uses name anyway"}The screen shows nothing but a "Toggle" button. Looks safe. But I asked the production server with curl and searched the HTML:
$ curl -s localhost:3201/leak | grep -o 'sk_live_[A-Z]*\|\$2b\$10[^\\"]*'$2b$10$abcdefSECRETHASHsk_live_TOPSECRETAnd the exact place in the payload:
"user":{"id":1,"name":"Asha","email":"asha@example.com","passwordHash":"$2b$10$abcdefSECRETHASH","apiKey":"sk_live_TOPSECRET"}Both secrets are sitting in the page source. Anyone can read them with "View source". The TypeScript type { name: string } on the Client Component did not help, because types are erased at build time. Passing user as any (or a wider type that happens to fit) was enough. The rule from the official guide is short: props to a Client Component are serialized and sent to the browser, so treat them as public.
Fix 1: send only what the screen needs
Make a small function that returns a DTO (data transfer object), and call that in the page:
// lib/dto.tsimport 'server-only'import { getUser } from './users'export async function getUserCard(id: number) { const u = await getUser(id) return { name: u.name } // only the name}// app/leak/page.tsx (fixed)import { getUserCard } from '@/lib/dto'import ProfileCard from '../ui/profile-card'export default async function Page() { const user = await getUserCard(1) return <ProfileCard user={user} />}I built again and checked:
$ curl -s localhost:3201/leak | grep -c "passwordHash\|sk_live"0$ curl -s localhost:3201/leak | grep -o '"user\\":{[^}]*}'"user\":{\"name\":\"Asha\"}Zero matches. Now the payload carries only {"name":"Asha"}.
A DTO is a boring idea and it is the best defence. The shape of the data you pass is the shape the whole world can read.
Fix 2: server-only, so a wrong import fails the build
The second protection is the server-only package. Add one line at the top of any file that must never reach the browser:
// lib/users.tsimport 'server-only'export type User = { /* ... */ }export async function getUser(id: number) { /* ... */ }Then I made a Client Component that imports getUser (a careless import, or an auto-import from the editor). The build refused:
./lib/users.ts:1:1Error: 'server-only' cannot be imported from a Client Component module> 1 | import "server-only"It should only be used from a Server Component.Import traces: Client Component Browser: ./lib/users.ts [Client Component Browser] ./app/ui/bad-client.tsx [Client Component Browser] ./app/ui/bad-client.tsx [Server Component] ./app/bad/page.tsx [Server Component](The same build printed a second, confusing message that said "Pages Router". That one came from the check for the server-side import, and it is noise. The import trace is the useful part: it shows the path from your page to the bad import.)
The docs note that installing server-only is optional in Next.js, because Next.js handles the import itself, but your linter may ask for the package. The matching package client-only marks code that must never run on the server, such as code that touches window.
What about environment variables?
The docs add a small safety net: only variables whose names start with NEXT_PUBLIC_ are put in the client bundle. A plain process.env.API_KEY in code that reaches the browser is replaced with an empty string, so the function just fails instead of leaking the key. Do not rely on it. Use server-only so the mistake is a build error and not a mysterious empty value. Lesson 8 covers environment variables in full.
Fix 3 (extra): taint
React also has an experimental API called taint. In next.config.ts you set experimental: { taint: true }, and then experimental_taintObjectReference and experimental_taintUniqueValue can mark an object or value so that React refuses to send it to a Client Component. The same flag also taints process.env, so you cannot pass it whole. I did not run this, so I only report what the docs say. The docs themselves warn that taint must not be your only protection. Use DTOs first.
Review checklist for any Client Component prop: (1) Is every field in the object something I would print on a public page? (2) Could this be a smaller object? (3) Is the data fetch in a server-only file? Authorization is a different question: see Access Control: IDOR, Roles and Mass Assignment for why "the user can see this page" does not mean "the user may see this field".
Passing data the other way: streaming with use
A Server Component does not have to await everything before it returns. It can start a request, not await it, and pass the pending promise to a Client Component. The Client Component reads it with React's use hook inside a <Suspense> boundary. While the promise is pending, the fallback shows.
// app/stream/page.tsx (Server Component)import { Suspense } from 'react'import Posts from '../ui/posts'async function getPosts() { await new Promise(r => setTimeout(r, 1200)) // pretend: a slow API return ['First post', 'Second post']}export default function Page() { const posts = getPosts() // NOT awaited return ( <main> <h1>Posts</h1> <Suspense fallback={<p>Loading posts...</p>}> <Posts posts={posts} /> </Suspense> </main> )}// app/ui/posts.tsx'use client'import { use } from 'react'export default function Posts({ posts }: { posts: Promise<string[]> }) { const list = use(posts) return <ul>{list.map(p => <li key={p}>{p}</li>)}</ul>}On the production server, curl gave me:
total 1.246s time to first byte 0.084stotal 1.210s time to first byte 0.012sThe heading and the "Loading posts..." text arrived in under 0.1 seconds, and the list came after 1.2 seconds. Both strings are in the final HTML (Loading posts... stays in the HTML as the fallback, and First post follows). This is streaming, and we study it fully in lesson 6. Remember the shape: start in the server, pass the promise down, read it with use.
The docs say you can resolve a promise either with await in a Server Component or with use in a Client Component. Use await when the server has no reason to wait less. Use use when you want the Client Component to render the shell first.
Context providers and third-party components
Context. React context does not work in Server Components. If you need a theme or similar value, make a small Client Component that wraps children in the provider, and use it in the layout:
// app/theme-provider.tsx'use client'import { createContext } from 'react'export const ThemeContext = createContext('light')export default function ThemeProvider({ children }: { children: React.ReactNode }) { return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>}// app/layout.tsx (still a Server Component)import ThemeProvider from './theme-provider'export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> )}Because children is passed in as a slot, the pages inside stay Server Components. This is the interleaving trick again. The docs also suggest you render a provider as deep in the tree as you can. It should wrap only the part that needs it, not the whole <html>. That helps Next.js optimise the static parts. For context details see Context, State Management and Choosing Where State Lives.
Third-party components. Many npm packages use useState but do not have 'use client' in them yet. If you import one straight into a Server Component, you get an error like the useState one above. The fix is a one-file wrapper:
// app/carousel.tsx'use client'import { Carousel } from 'acme-carousel'export default CarouselNow any server page can import Carousel from './carousel'. Library authors should add 'use client' to their entry files so you do not need the wrapper.
A simple way to decide
When I build a page, I ask three questions in order:
- Does this piece need state, effects, handlers or a browser API? If no, it stays a Server Component. Done.
- If yes, what is the smallest piece that needs it? Cut that piece into its own file with
'use client'. A button, not the whole card. - Does the client piece need server data? Fetch it in the server parent, pick only the needed fields, and pass them as props (or as a promise for
use).
And a short list of "starts as Server, ends as Client" examples:
| Piece | Server or Client? |
|---|---|
| Blog post body, article text | Server |
| A list of products from a database | Server |
| A "like" button with a counter | Client (small file) |
| Search input that filters as you type | Client |
| A modal that opens and closes, with server content inside | Client shell with Server children |
| A dropdown menu | Client |
| Layout with logo, links, footer | Server (with a small Client search box inside) |
<details> / <summary> toggle | Server (the browser handles it, no JavaScript needed) |
The last row is a good reminder from the docs: built-in HTML behaviour (<details>, <video controls>, a <form> with a Server Action) does not need a Client Component at all.
Common mistakes
1. 'use client' on layout.tsx or page.tsx. It works, but it drags a large part of the app into the bundle and blocks direct database reads in that file. Extract the interactive piece.
2. Passing a whole database row to a Client Component. Everything in the props is public. Use a DTO. I showed the password hash and API key in the page source.
3. Importing a Server Component inside a Client Component file. It silently becomes client code (my "Way B" test). Pass it as children or another prop from a Server parent instead.
4. Reading window or localStorage during render. Client Components also run on the server. Read browser values in useEffect.
5. Thinking "Server Component" means "no HTML on the server for Client Components". Both kinds make HTML on the server. The difference is whether the code is also sent to the browser.
6. Trusting TypeScript types to hide fields. Types are removed at build time. The runtime data is what is serialized.
Try it
Make a fresh app (or reuse the one from lesson 0) and try these. Answers follow.
- Create
app/page.tsxwithconsole.log('page'). Runnpm run dev. Where does the log appear: the terminal or the browser console? - Create
app/ui/button.tsxwith'use client'and aconsole.log('button')in the component body, and render it in the page. Reload. Where do you seebuttonnow? Click a<Link>away and back. Does it log on the server again? - Add
import { useState } from 'react'to the page (no directive). What happens? - Import
marked(or any big library) in a Client Component, build, and compare the total size of.next/static/chunks/*.jsbefore and after moving it into the page. - Make a
lib/secret.tsthat starts withimport 'server-only'and exports a constant. Import it from a Client Component. What doesnext buildsay? - Pass
{ name: 'A', secret: 'XYZ' }from a page to a Client Component that only readsname. Usecurlon the built page. IsXYZin the HTML? Fix it. - Wrap a Server Component
<Cart />in a Client Component<Modal>aschildren. Then importCartinsideModalinstead. Which version puts theCarttext into the client chunks?
Answers
- In the terminal (the server). The browser console stays empty, because the page code never runs there.
- On the first load you see
buttonin both the terminal and the browser console (server render, then hydration). After a client-side navigation back, it renders only in the browser, because the server sends the payload and not new HTML. - The build stops with: "You're importing a module that depends on
useStateinto a React Server Component module". - The client total grows when the library is imported by a Client Component. It stays the same when the library is used in a Server Component (the page).
- It fails with "'server-only' cannot be imported from a Client Component module" and shows the import trace.
- Yes,
XYZis in the HTML, inside the payload scripts. Fix it by passing{ name }only (a DTO). - The second version (
Cartimported byModal). When it comes in aschildren, it stays on the server.
Interview questions
What is the difference between a Server Component and a Client Component? (Must know)
A Server Component runs only on the server, so its code is never sent to the browser. It can be async, read a database, and use secrets. A Client Component is a component in a file with 'use client'. It is rendered to HTML on the server, and its code is also sent to the browser, where React hydrates it. It can use state, effects, event handlers and browser APIs. In the App Router every component is a Server Component by default. In my test, a Client Component's console.log printed once in the build terminal (with typeof window undefined) and again in the browser console (with typeof window object).
Does a Client Component render on the server too? (Trap)
Yes. The name suggests it is browser only, but Next.js renders it to HTML on the server (at build time or at request time) so the first paint has content. Then the browser downloads its JavaScript and hydrates it. That is why touching window in the render body fails with "window is not defined", and why the page shows correct text even with JavaScript turned off, though the buttons are dead. I tested both with Playwright.
What does 'use client' actually do? (Must know)
It marks a boundary between the server module graph and the client module graph. The file with the directive, and everything it imports, go into the client bundle. So I put it only on the file where the interactive part starts, not in every file. It does not affect components that I pass in as children or other props from a Server parent. Those are rendered on the server first. I proved that: a Cart passed as children stayed out of the client chunks, and a Cart imported by the client file ended up in them.
How can I put a Server Component inside a Client Component? (Must know)
I cannot import it in the client file, because that turns it into client code. I pass it from a Server parent, as children or another prop: <Modal><Cart /></Modal>. The server renders Cart and sends its output, and Modal just places it in its slot. This is called interleaving. It is also how a context provider can wrap the app in layout.tsx without making all pages client code.
What is the RSC payload? (Must know)
It is a compact serialized description of the rendered server tree. It contains the output of the Server Components, placeholders for Client Components with references to their JavaScript files, and the props passed to them. On the first visit it comes inside the HTML (as scripts), and on client-side navigation the browser fetches it for the new route. I looked at a prerendered one: a Client Component showed as ["$","$L2",null,{"start":5}], with no source code of any Server Component, only their output.
What can I pass as props from a Server Component to a Client Component? (Must know)
Serializable values: strings, numbers, booleans, plain objects, arrays, and some built-ins like Date, Map, Set and bigint, which worked in my test. Not plain functions and not instances of my own classes. Both gave a 500 with a clear message. The one exception for functions is a Server Action marked with 'use server', which is passed as a reference.
How can a secret leak to the browser in the App Router, and how do I stop it? (Senior signal)
Three ways. First, passing a whole object (like a user row with a password hash) as a prop to a Client Component. I showed this with curl: the hash and an API key were in the page source even though the screen showed only a button. Second, importing server-only code in a client file, so its imports and strings enter the bundle. Third, using a secret process.env value in code that reaches the client. My defences: return small DTOs from a data layer so only needed fields exist, put import 'server-only' at the top of server files so a bad import fails the build (I saw the error and import trace), only use NEXT_PUBLIC_ for values that are meant to be public, and optionally use React taint. TypeScript types do not help, because they are erased.
Why is the bundle smaller with Server Components? (Must know)
Because the code of a Server Component, and the libraries only it uses, never go to the browser. Only its rendered output does. I measured it: using marked in a Server Component left it out of the client chunks, and moving the same call into a Client Component added about 45 KB raw (13 KB gzipped) to the client JavaScript. For a big dependency like a syntax highlighter or a date library, that is a real gain in download and parse time.
When would you still pick a Client Component? (Must know)
When the piece needs state, event handlers, effects, a custom hook that uses them, or a browser API like localStorage or geolocation. Examples are a like button, a search box, a modal toggle, a dropdown. I keep that piece small and put it in its own file, and I leave the content around it on the server.
What is hydration, and what is a hydration mismatch? (Senior signal)
Hydration is React attaching event handlers and state to HTML that the server already produced. For this to work, the first render in the browser must produce the same output as the server HTML. A mismatch happens when something differs between them: Date.now(), Math.random(), window checks, or locale formatting done differently. React warns, and may throw away the server HTML and redo it. I avoid it by rendering a stable default first and putting browser-only values into useEffect.
How do you fetch data and pass it to a Client Component without blocking the page? (Senior signal)
I start the fetch in a Server Component and do not await it. I pass the promise as a prop to a Client Component inside a <Suspense> boundary, and the Client Component reads it with use(promise). The server sends the rest of the page first and streams the data later. In my test the first byte came at 0.08 s while the full response took about 1.2 s. If the data is not needed in the client, I just await it in the Server Component, which is simpler.
A third-party component uses useState and fails inside a Server Component. What do I do? (Trap)
I make a small wrapper file with 'use client' that imports and re-exports the component, and use the wrapper in my server code. I do not mark my whole page as client. If I maintain the library, I add 'use client' to its entry points so users do not need the wrapper.
Key takeaways
- In the App Router every component is a Server Component by default. It runs only on the server and its code is not sent to the browser.
'use client'creates a boundary. That file and everything it imports ship to the browser. Put it on small interactive files, not on pages and layouts.- A Client Component also renders on the server for the first HTML. It is "also on the client", not "only on the client".
- Pass Server Components into Client Components as
childrenor props, never by importing them in the client file. - Props crossing the boundary are serialized and public. Plain data,
Date,Map,Setandbigintwork. Functions (except Server Actions) and class instances fail. - Never pass a whole database row. Send a DTO. Add
import 'server-only'to server files so a wrong import is a build error. - Browser APIs belong in
useEffect. Keep the first render the same on the server and in the browser. - To stream data into a Client Component, start the promise on the server, do not await it, and read it with
useinside<Suspense>.
What's next
Next: Data Fetching and Caching in the App Router. Now that you know a Server Component can await data directly, the questions are how often that data is fetched, how requests in one page can run in parallel, and what Next.js remembers between requests. You will see the caching model in this version of Next.js, including the use cache directive and the cacheComponents option, with real output. Practise now: take any page you made and move one interactive piece into its own tiny 'use client' file. Then run next build and compare the client JavaScript size before and after.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Slow First Load: Bundles, Code Splitting and LCP · Scenario Debugging
- Concurrent React, Suspense and Server Components in Next.js · Senior Frontend Depth
- Next.js Interview Questions · Full-Stack Questions
Part of the study guide
Related series