Skip to content
intermediate Phase 3 · Server and Client Components

Crossing the Client Boundary

Compose interactive islands inside server-rendered trees.

50m
0 problems
Topic Progress 0%

Passing JSX as Children So the Page Around the Widget Stays on the Server

You are building a settings screen: a <Tabs> widget that flips between panels, wrapped in a page that must query the database and render a long description of each section. The tempting move is to mark the whole page 'use client' so the tabs and their content live together. The composition pattern avoids that entirely: keep the page a Server Component and hand the tabs server-rendered JSX as children.

// app/settings/page.tsx — Server Component, stays on the server
import db from '@/lib/db';
import Tabs from '@/components/Tabs';      // client component
import AuditLog from '@/components/AuditLog'; // server component

export default async function SettingsPage() {
  const sessions = await db.session.findMany();
  return (
    <Tabs labels={['Profile', 'Sessions', 'Security']}>
      <section>{/* server-rendered profile form */}</section>
      <section>
        <AuditLog sessions={sessions} />  {/* executed on the server */}
      </section>
      <section>{/* server-rendered security settings */}</section>
    </Tabs>
  );
}
// components/Tabs.tsx — client island
'use client';
import { useState } from 'react';

export default function Tabs({ labels, children }) {
  const [active, setActive] = useState(0);
  return (
    <div>
      <div role="tablist">{labels.map((l, i) => (
        <button key={l} onClick={() => setActive(i)}>{l}</button>
      ))}</div>
      <div role="tabpanel">{children}</div>
    </div>
  );
}

Why does this work when passing an onClick does not? Because the children JSX is created by server code. React serializes it as elements, and the server executes each child before the client component ever starts rendering — React's docs put it plainly: the child component is fully executed before Tabs begins. The client widget receives finished output, not server source, and the Tabs module never imports AuditLog at all, so AuditLog is not pulled into the client module graph. The boundary cuts imports, and there is no import here.

Server parent (SettingsPage) Client widget (Tabs)
Runs On the server, per request Server for initial HTML, then the browser
Can await data Yes No — fetch before passing down
Owns the children's code Yes — renders it and keeps it server-side No — only renders the output it receives
Knows what children are It authored the JSX It sees opaque slots; no introspection

Three practical rules fall out of the model:

  1. Pass content down, not behavior in. You may hand children (or any serializable prop) from server to client, but a function authored in server code cannot cross — attaching onClick inside the server JSX, or passing a callback prop into Tabs, triggers Event handlers cannot be passed to Client Component props. The exact constraints live in Props Serialization Constraints.
  2. If your client widget tries to import the section content instead of receiving it, that content joins the client graph — async server data code then fails at the boundary. Receive it as props; never import it.
  3. Props other than children work the same way. <Tabs panels={panelElements} /> serializes too; children is convention and ergonomic sugar (it nests JSX naturally), not a magic slot.

This is the pattern that keeps The use client Directive where it belongs: on the leaf that actually needs state, while the expensive, data-heavy surroundings remain server-rendered.

One capability worth knowing: because children arrive as streamed server output, wrapping them in <Suspense> inside the client widget behaves exactly as you would hope — the fallback paints immediately and each panel streams in as the server finishes it. What you cannot do is inspect or enhance them: Tabs cannot read a child's props, attach an onClick to its root, or clone it with extra behavior. Treat slots as opaque. If a panel needs its own interactivity, the server authors a small client component for that slot instead.

Data Down, Actions Up: The Two-Way Contract Across the Boundary

A boundary is only useful if you know what may travel in each direction. Server to client carries data: serializable props, rendered children, promises. Client to server carries intents: Server Function calls, route-handler requests, refresh signals. Nothing else crosses.

Down — data. The server page fetches and passes results as props. In Next.js 15 note the caching default flipped: fetch is no longer cached by default (no-store unless you pass cache: 'force-cache'), and GET route handlers are uncached too, so a server page queried per request always hands fresh props to its islands. What may descend is exactly the serializable set — primitives, plain objects, arrays, Date, Map, Set, promises, JSX — because the props are embedded in the streamed RSC payload.

Up — Server Actions. Mark a module 'use server' and its exports become callable endpoints the client can invoke. React 19's useActionState gives the client the pending state and the action's return value:

// app/actions.ts
'use server';
export async function renameTeam(prevState, formData) {
  const name = formData.get('name')?.toString() ?? '';
  if (name.length < 3) return { error: 'Too short' };
  await db.team.update({ where: { id: formData.get('id') }, data: { name } });
  return { ok: true };
}
// components/RenameForm.tsx — client island
'use client';
import { useActionState } from 'react';
import { renameTeam } from '../app/actions';

export function RenameForm({ teamId }) {
  const [state, formAction, pending] = useActionState(renameTeam, { error: null });
  return (
    <form action={formAction}>
      <input type="hidden" name="id" value={teamId} />
      <button disabled={pending}>{pending ? 'Saving…' : 'Rename'}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Arguments and return values obey the same serialization rules as props — the action reference itself is the only function that crosses, as an ID rather than a code blob. Outside a <form>, call actions inside startTransition so you get isPending; forms wrap them automatically, and React replays pre-hydration submissions, so the button works before the JS lands.

Up — route handlers. For reads, the island calls fetch('/api/invoices') and gets JSON. The route handler is a second boundary with JSON semantics (silent type coercion, no functions), which the serialization topic contrasts with the Flight encoding.

Up — refresh. After a mutation, useRouter().refresh() re-renders the route's Server Components on the server and swaps the payload in place — no full navigation, layouts preserved — so islands see fresh server data on the next tick.

The full contract:

Direction Mechanism Payload rule
Server → client props, children, promises Serializable only (Flight encoding)
Client → server Server Actions ('use server') Args and results serializable
Client → server fetch route handlers JSON: types coerce, functions vanish
Client → server router.refresh() No payload; requests fresh server render

One more structural piece: context cannot bridge the boundary. createContext has no server implementation, so every provider file is client code — but the provider receives server children through the children pattern from chapter 1, which is why a single 'use client' Providers wrapper around the root layout does not drag your data layer into the browser.

Two React 19 helpers round out the up-channel. useFormStatus lets a component deeper in the tree read the nearest form's pending state without prop drilling, and useOptimistic displays an anticipated result while the action is still in flight; both are client hooks consuming results that crossed the boundary as ordinary serializable state. When an action throws, React ships back only a digest for the server logs — the Error object itself never crosses, another instance of the rule that anything failing serialization stays on the side where it was created.

When the Boundary Is in the Wrong Place: the Whole-Page 'use client' Autopsy

A teammate fixes a hook error by adding 'use client' to page.tsx. The build passes. Three things are now wrong, and each leaves evidence you can read.

Symptom 1 — the route report. Run next build and compare the route's size line before and after the change. The directive marks the page module and its transitive imports as client code, so every route-local import — the markdown renderer, the chart library, the icon set — enters the browser chunk. On a real app it is common to see a route's first-load JS jump from roughly a hundred kilobytes to several hundred; you pay those bytes on every visit, and hydration does the work twice (server already rendered it). The fix is never "remove interactivity" — it is pushing the directive down to the leaf that actually raised the hook error, as covered in The use client Directive.

Symptom 2 — data fetching collapses into effects. A client component cannot be async in React 19, so the page's await db.query(...) cannot survive the directive. The code "works" after someone moves fetching into useEffect, which now runs only after hydration: HTML, then bundle, then request. Server-rendered data became client waterfall.

Symptom 3 — hard errors for server-only exports. Two messages give the game away immediately:

You are attempting to export "metadata" from a component marked with
"use client", which is disallowed. Either remove the export, or the
"use client" directive.
`ssr: false` is not allowed with `next/dynamic` in Server Components.
Please move it into a Client Component.

The first means the segment file itself got marked — metadata must resolve before render, on the server. The second is the classic lazy-load mistake: { ssr: false } tells React to skip a server pass, which is meaningless for a component that has no client pass to begin with. The fix is a wrapper, not a page-level directive:

// RevenueChartLoader.tsx — the ONLY client file involved
'use client';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./RevenueChart'), {
  ssr: false,
  loading: () => <div className="chart-placeholder" aria-busy="true" />,
});

export default function RevenueChartLoader() { return <Chart />; }
// app/dashboard/page.tsx — stays a Server Component
import RevenueChartLoader from './RevenueChartLoader';
export default function DashboardPage() {
  return <main><h1>Revenue</h1><RevenueChartLoader /></main>;
}

Note what the wrapper buys: only dynamic, the loader, and the chart's own dependencies join the client graph; the page's other imports stay server-side. If the chart library reads window at module scope, importing it statically anywhere in server code still crashes prerender with ReferenceError: window is not defined — the dynamic() call must live behind the directive.

The interview scenario. "This page's JS doubled after a refactor. How do you find the cause?" Walk the evidence: (1) next build route sizes to confirm the jump; (2) grep for 'use client' in files that export no interactive component — page- and layout-level markers are the usual culprits; (3) check whether data fetching moved into effects; (4) move each directive to the smallest interactive leaf, rebuild, and report the number you got back. That loop — measure, relocate, re-measure — is the whole discipline of drawing the boundary.

One audit pairs with it: mark your data-layer modules with the server-only package and secret-holding modules fail fast with a clear error the moment anything in the client graph imports them, instead of quietly shipping credentials in a chunk. Next.js resolves server-only and client-only internally for exactly this purpose, so it costs one import line and pays out every time a boundary moves.

Quiz

1. A Server Component passes JSX children into a 'use client' Tabs component. Why does the parent stay a Server Component?

Question 1 options

2. A server page calls dynamic(() => import('./Chart'), { ssr: false }). What happens?

Question 2 options

3. A server page passes onClick={() => track('buy')} into a client BuyButton. What is the correct resolution?

Question 3 options

4. After a mutation, a client island needs the route's Server Components re-rendered with fresh data. What is the idiomatic call?

Question 4 options

Flashcards

Question

How does a Server Component pass content into a client widget without shipping its own code?

Answer

By passing JSX as children (or as any serializable prop). The server executes that JSX first and serializes the rendered result; the client widget never imports the children's modules, so their code stays out of the client bundle. Children passed this way can be async server components doing database work.

Question

What are the three sanctioned ways for a client component to send something back to the server?

Answer

Server Actions (functions marked 'use server', called from forms or transitions, args and results serializable), fetch calls to route handlers (JSON semantics), and router.refresh() to re-render the current route's server components in place after a mutation.

Question

Why can't a Server Component pass an event handler or callback into a client component?

Answer

Because the prop would have to be serialized into the streamed RSC payload, and closures cannot be transferred — React throws 'Event handlers cannot be passed to Client Component props.' Pass data instead and let the client define its callback, or pass a Server Function ('use server'), which crosses as a serializable endpoint reference.

Question

What three failures indicate someone marked an entire page 'use client'?

Answer

(1) The route's first-load JS balloons in the next build report because the whole import graph shipped; (2) data fetching migrates into useEffect, because client components cannot await, adding a post-hydration waterfall; (3) hard errors for server-only exports such as metadata ('disallowed') or next/dynamic ssr:false ('not allowed in Server Components').

Question

Where must a React context provider live, and how does server content pass through it?

Answer

createContext has no server implementation, so the provider file must be a client component ('use client'). Server content passes through it as children props — the root layout (server) wraps the app in the client Providers component, and everything below stays server-rendered because the provider never imports it.

Revision Notes

Key Takeaways

  • 1. Pass JSX as children so the expensive, data-heavy part of a page renders on the server while only the interactive widget ships JavaScript
  • 2. The boundary cuts the module graph, not the render tree — server children are executed before the client widget and never imported by it
  • 3. Data flows down as serializable props; intents flow up via Server Actions, route handlers, or router.refresh() — server-authored callbacks never cross
  • 4. A page-level 'use client' costs three times: bundle bytes, an effect-driven fetch waterfall, and server-only exports like metadata that now fail the build
  • 5. The fix for ssr:false and lazy widgets is a one-file client wrapper, which confines the client graph to that wrapper and the lazy chunk

Interview Tips

  • • "How do you mix server and client components in one page?" — composition: server renders the page and data, client components receive JSX children; name the direction of data (down) and actions (up) to show you understand the contract, not just the syntax
  • • "You can't pass onClick from the server. Then what?" — pass data down and define the handler client-side, or pass a Server Function marked 'use server' (it crosses as an endpoint reference, not code); both answers earn credit, only the second shows you know why functions fail
  • • "How would you cut a page's JavaScript in half?" — cite the build report as evidence, find 'use client' above leaf components, relocate it to the smallest interactive file, and re-measure; mentioning the await→useEffect side effect proves you saw the real cost
  • • "Why can't I put my theme provider on the server?" — createContext has no server implementation, so providers are client files; the trick is that they accept server-rendered children, so one small provider at the root does not make the tree client

Cheat Sheet

Crossing the Client Boundary

Composition (keep the page server-side):

// server page
<Tabs labels={[...]}>
  <ServerPanel />   {/* rendered by the server first, shipped as output */}
</Tabs>
// client Tabs: 'use client', receives children as opaque slots

Rule: content down, behavior stays where it is authored — server JSX can go down; server-authored functions cannot.

The two-way contract:

Direction Mechanism Payload rule
Server → client props / children / promises Serializable only (Flight)
Client → server Server Action ('use server') Args + results serializable; call in a form or startTransition
Client → server fetch('/api/...') route handler JSON — types coerce, functions/Map/BigInt lose or throw
Client → server router.refresh() No payload; re-renders server components in place

Boundary failure signatures:

Evidence Reading
Route first-load JS doubled in next build 'use client' crept up to page/layout level
Fetching moved into useEffect page-level directive killed await — client components cannot be async
exporting "metadata" from a component marked with "use client" segment file over-marked; keep it server
`ssr: false` is not allowed with `next/dynamic` in Server Components move dynamic() into a 'use client' wrapper

Providers: createContext is client-only — wrap server children in one small client file instead of marking the tree.

Continue Learning