Type something to search...
Next.js The Server and Client Boundary

Next.js The Server and Client Boundary

The "Server and Client Components" article earlier in this series covers the basics — how to decide which one a piece of UI should be, the everyday composition patterns. This one goes underneath that: what "Server Component" and "Client Component" actually mean as a compilation and module-graph concept, not just as a mental model, and precisely what does and doesn't cross the line between them. If you've ever hit "Element type is invalid" from a compound component, or wondered why passing a function prop from a Server to a Client Component sometimes just works and sometimes throws, this is the layer that explains why.

What the boundary actually is

React Server Components split a component tree across two separate module graphs — server and client — and this split determines two genuinely distinct things: where a given piece of component code runs, and whether that code ever ships to the browser at all. Server Components stay exclusively server-side. Client Components handle interactive UI, and both kinds compose together in a single tree, which the server renders into the RSC Payload — a serialized description of the UI carrying references to whichever Client Components sit inside it.

It helps to know what came before this model, because it clarifies exactly what changed. Before RSC, React components all followed what the framework now retroactively calls the Client Component model: React could render them to HTML on the server, sure, but the same code also shipped to the browser regardless, in order to hydrate that HTML into an interactive tree. Hydration is precisely what makes static HTML interactive — and it required the component to produce matching output both server-side and in the browser. A fully client-rendered app skipped server rendering of the tree entirely and just started from an empty shell.

Server Component
├─ Server Component
└─ Client Component
   └─ Client Component

Under the hood, each module a component lives in belongs to the server module graph, the client module graph, or genuinely both — and when a module is shared between the two, Next.js compiles it separately for each environment, as two distinct build artifacts from one source file. During rendering, the server graph produces references to whatever Client Components it contains and serializes their props; critically, the client graph never imports the server graph at all — it only ever receives references and already-serialized props, arriving through the RSC Payload.

Two questions, not one: where it renders vs. what ships

The component names ("Server," "Client") suggest a cleaner separation than what actually happens, because rendering genuinely occurs in both places for one of the two component types. The table that actually clarifies this:

Runs on the serverRuns in the browser
Server ComponentYesNo
Client ComponentYesYes

The word "Client" in "Client Component" isn't saying "this only runs in the browser" — it's saying "this also runs in the browser, alongside its server render." A direct visit to a page containing a Client Component genuinely executes that component's code twice: once server-side to produce initial HTML, and again in the browser during hydration.

// app/hello.tsx
"use client";

export default function Hello() {
  console.log("Hello rendered"); // on a direct visit: fires in the terminal, then the browser console
  return <p>Hello</p>;
}

On a subsequent client-side navigation to this component (rather than a fresh page load), the server sends only the RSC Payload, and the component renders purely in the browser — no server-rendered HTML involved that time, since the client already has everything it needs.

Worth being precise about a distinction that's genuinely easy to conflate: rendering a Client Component on the server still produces HTML, exactly like SSG, ISR, or ordinary SSR always have — none of that is new, and predates RSC entirely. What RSC actually adds is a separate mechanism that keeps Server Component code server-only and emits the RSC Payload instead of shipping that code to the client. "Server-rendered" describes how the HTML got produced. "Server Component" describes where the code lives and whether it ever reaches the browser. These are related but genuinely separate facts about any given component, and conflating them is a common source of confusion when reasoning about what actually ships in a bundle.

The SEO implication worth stating explicitly

A crawler that only reads HTML — doesn't execute any JavaScript — sees just the first response, and both Server and Client Components contribute HTML to that same response. So SEO here isn't really a question of "did I use a Server Component" at all; it's a question of whether the server render actually reaches the content in question. Content gated behind a user interaction or a client-only event genuinely doesn't appear in the HTML a non-JS-executing crawler receives, regardless of whether the component producing it happens to be a Server or Client Component.

How data actually gets into the tree

The pre-RSC pattern — getStaticProps/getServerSideProps fetching data, then handing it down as props before the tree ever rendered — flowed strictly in one direction:

Data → Loader or API → Props → Component tree

RSC changes this meaningfully: because a Server Component runs only on the server, it can reach directly into a database, the filesystem, an internal service, or a secret — during its own render, with no separate API route needed to expose that data to the client first.

// app/page.tsx
import { PostList } from "@/app/ui/post-list";
import { getPosts } from "@/lib/data";

export default async function Page() {
  const posts = await getPosts(); // runs server-side, during render — no API route needed
  return <PostList posts={posts} />;
}

There's a real security implication worth flagging clearly, precisely because a Server Component's direct access to secrets and server-only data is such a genuine convenience: be deliberate about what you actually pass down to a Client Component from here. Props crossing that boundary get serialized and shipped to the browser as part of the payload — anything you pass is, by construction, visible client-side.

Streaming data into a Client Component without waiting for it first

Server Components don't need to await every piece of data before returning UI at all. To stream server-initiated data into a Client Component, start the fetch server-side and pass the still-pending promise down as a prop, rather than awaiting it first:

The Client Component reads that promise as a resource via React's use(). While it's still pending, the nearest <Suspense> boundary shows its fallback; the component itself renders only once the promise actually resolves. Because the request started on the server, before any client code even ran, the Client Component genuinely doesn't need its own fetch after mount for this same data — you'd still fetch client-side separately only for data that itself depends on client-only state or a user interaction that hasn't happened yet.

Two caching details worth knowing here: identical fetch requests get automatically memoized during a single server render, so calling the same fetch from multiple places in the tree doesn't multiply the actual network cost. And with Cache Components specifically, you can wrap a data function or component in use cache and revalidate that one cache entry entirely independently of the rest of the page — a capability covered in depth in the dedicated caching articles elsewhere in this series.

What actually distinguishes state and interactivity here

A Server Component's code, being server-only by construction, simply never reaches the browser — it can only ever re-render when Next.js re-renders the route itself: on a navigation, a manual refresh, or a revalidation event. A Client Component's code, by contrast, genuinely does reach the browser, gets hydrated by React on initial load, and can re-render client-side afterward in response to ordinary client-side updates.

One operational detail worth knowing precisely, because getting this wrong produces genuinely confusing bugs: mutating a Server Component's rendered DOM nodes directly can desynchronize the DOM from React's own component tree. The correct way to update Server Component output is to render the component again, server-side — when the browser receives a fresh RSC Payload as a result, React reconciles the tree properly and updates the DOM through its own normal mechanism, rather than through a manual, out-of-band DOM mutation that React has no awareness of.

This directly explains why useState, useEffect, and event handlers are Client-Component-only concepts: all three require code that actually executes in the browser and can respond there to subsequent updates. Server Component code, again, never reaches the browser at all — there's no runtime present there for these APIs to operate against.

Not every interaction needs a Client Component

This is genuinely easy to over-apply as a blanket rule ("interactive → must be Client Component"), and it's worth knowing where it doesn't hold: built-in browser and HTML behavior can provide real interactivity with no Client Component involved whatsoever. A <details> element opens and closes using nothing but native browser behavior. A <form> can submit through a Server Function passed directly to its action prop. A <video controls> element plays and pauses using the browser's own built-in controls.

The actual dividing line: reach for a Client Component specifically when the behavior needs browser state that changes over time — a controlled input, a live filter as the user types, a drag handle tracking pointer position. An ordinary button or form doesn't need one at all when the browser's native behavior already covers everything required.

Crossing the boundary: two rules, and what breaks them

'use client' draws the actual line in the module graph, and exactly two rules govern what's allowed to cross it:

Code crosses through imports. Whatever a Client Component imports gets pulled into the client bundle, full stop — there's no partial-import mechanism that lets a Client Component import only "the server-safe half" of something.

Data crosses through props, and it must be serializable — which is exactly why a plain function, like an ordinary event handler, cannot cross this boundary at all.

// This throws: a plain function passed as a prop from a Server
// Component to a Client Component cannot cross the boundary.

A Server Function specifically — one marked 'use server' — is the deliberate exception: it crosses as a reference, not as executable code, which is precisely the mechanism that makes Server Actions work as a prop at all. One genuinely subtle detail worth knowing if you're using TypeScript: a Server Function isn't distinguishable from an ordinary function purely by its type signature — the TypeScript plugin specifically allows a Client Component prop typed as a function through when its name is exactly action or ends in Action, and flags every other function-typed prop as suspect. This naming convention isn't cosmetic; it's what the tooling actually keys off of to know a given function prop is meant to be a Server Function reference rather than an impossible plain-function crossing.

children is the escape hatch that avoids pulling server code into the client bundle

A rendered React element genuinely can cross this boundary, because — unlike a function — it's already serializable data by construction. This is exactly what lets a Server Component nest inside a Client Component without dragging the Server Component's own source code into the client bundle at all:

// app/page.tsx — Page and Cart are Server Components. Modal is a Client Component.
import { Cart } from "@/app/ui/cart";
import { Modal } from "@/app/ui/modal";

export default function Page() {
  return (
    <Modal title={<div>Your cart</div>}>
      <Cart />
    </Modal>
  );
}
// app/ui/modal.tsx
"use client";

import { useState, type ReactNode } from "react";

export function Modal({
  title,
  children,
}: {
  title: ReactNode;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(true);
  if (!open) return null;

  return (
    <div role="dialog">
      <header>
        {title}
        <button onClick={() => setOpen(false)}>Close</button>
      </header>
      {children}
    </div>
  );
}

Modal receives title and children purely as already-rendered, serialized React elements — it places them where its own JSX says to, but it never sees Cart's source code at all, only its finished output. Cart genuinely runs server-side; Modal merely arranges where its output lands in the DOM.

Owner versus parent is the underlying concept worth naming here, since it's what makes this work: the owner of a component is whichever component's source actually contains the JSX creating it — Page owns both Modal and Cart in the example above. The parent is whatever directly contains a component in the rendered tree — Modal is Cart's parent there, despite never having imported it. Because Cart's owner is a Server Component, Cart renders server-side regardless of the fact that its rendered-tree parent happens to be a Client Component — this owner/parent split is precisely what lets a Client Component display a Server Component's output without ever importing that Server Component itself.

Where this pattern quietly breaks: compound components

Compound components — exposing subcomponents as static properties, like Menu.Item or Tabs.Panel — work fine as long as every piece involved lives in the same module graph, entirely Server or entirely Client. The pattern breaks specifically the moment a static member tries to cross the boundary: a Server Component importing a Client Component receives only a client reference in place of the actual function object, which means Menu.Item resolves to undefined on the server side, and React throws "Element type is invalid" — a genuinely confusing error if you don't already know this specific cause. The fix: use a compound Client Component only from another Client Component, and if a Server Component genuinely needs access to its pieces, expose them as ordinary named exports instead of static properties on the parent.

You only need the directive at the entry point, not on every file

'use client' only needs to sit at the entry to a client subtree — every module that entry subsequently imports automatically becomes part of the client module graph too, with no need to repeat the directive file by file down the tree.

A useful pattern worth knowing for keeping a genuinely shared component unchanged: create a thin Client Component wrapper around it, importing the shared module, and place 'use client' on the wrapper rather than on the shared component itself. This keeps the shared module untouched (usable from either graph as needed) and moves the actual boundary closer to your application-specific code — and it conveniently avoids needing to add the directive to every shared utility component that happens to call useState or useEffect internally. And if client-only code somehow leaks into the server graph without a proper boundary in place, the compiler itself points directly at exactly where the missing directive needs to go, rather than leaving you to guess.

Key Takeaways

QuestionAnswer
Does a Server Component ever run in the browser?No, never
Does a Client Component run only in the browser?No — it runs server-side too, then again during hydration
What determines SEO visibility?Whether server render reaches the content — not the Server/Client label itself
Can a plain function prop cross Server → Client?No — only a Server Function ('use server'), which crosses as a reference
Can rendered JSX (children) cross the boundary?Yes — it's serializable data, unlike a function
Where does 'use client' need to go?Only at the entry point of a client subtree — it propagates automatically to imports
Common failure modeCompound components (Menu.Item) break crossing the boundary — use named exports instead

The Server/Client boundary is ultimately a compile-time, module-graph-level construct, not a runtime toggle you flip per component — which is exactly why its rules (what crosses, what doesn't, where the directive needs to live) are strict and mechanical rather than a matter of judgment call. Once the owner/parent distinction and the "code crosses via imports, data crosses via serializable props" split actually click, most of the genuinely confusing errors this boundary produces — the invalid function prop, the undefined compound-component member — stop being mysterious and start being predictable consequences of a small, consistent rule set.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper