How it works
The explanation, with examples you can run.
What the browser receives
For a first visit to a route, Next.js sends:
- HTML, so something is visible before any JavaScript runs.
- The RSC Payload: the rendered Server Component tree, the props passed to client components, and placeholders that point to the client component code.
- JavaScript for the client components on the page, which React uses to hydrate them.
When the user then follows a <Link>, the router fetches only the RSC Payload for the segments that changed (often already prefetched), and React merges it into the page without a full reload. Server Component code itself, and the libraries it imports, never appear in any of these.
Placing the boundary
// app/products/[id]/page.tsx (a Server Component)import { getProduct } from "@/lib/data"import AddToCart from "./add-to-cart" export default async function Page({ params }: PageProps<"/products/[id]">) { const { id } = await params const product = await getProduct(id) return ( <article> <h1>{product.name}</h1> <p>{product.description}</p> <AddToCart productId={product.id} /> </article> )}// app/products/[id]/add-to-cart.tsx"use client"import { useState } from "react" export default function AddToCart({ productId }: { productId: string }) { const [count, setCount] = useState(1) return <button onClick={() => setCount(count + 1)}>Add {count} ({productId})</button>}Only AddToCart and its imports join the client bundle. The props crossing the boundary must be serialisable (strings, plain objects, dates, promises, JSX, Server Functions), not ordinary functions or class instances.
Two patterns keep the boundary low. Pass server-rendered content as children into an interactive wrapper (<Tabs>{panel}</Tabs>), so the panel stays a Server Component. And wrap context providers in a small client component that takes children, then render that provider from the root layout; Server Components can't use context themselves.
Keeping server things on the server
Environment variables are read at runtime on the server, but in client code only variables prefixed NEXT_PUBLIC_ are inlined at build time; any other name is simply missing in the browser. The more realistic leak is a shared helper that reads a secret or a database being imported by a client component. Put import "server-only" at the top of such modules and that mistake becomes a build error. The Data Security guide recommends a single data access layer that checks authorisation and returns only the fields the UI needs, so a whole database row never lands in props.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.