An App Router page and layout render as Server Components unless a client boundary is declared. Server code can read private services and render data without shipping that code as browser JavaScript. A Client Component can own input state, event handlers, and browser APIs. Crossing from server to client is a data-delivery boundary: its props must be serializable for React and may be visible in page or navigation payloads. A secret environment value, a database handle, or an unauthorized case record does not become safe because it was passed from a server file. Mark a small interactive leaf as client code and pass only the permitted fields it needs. The owner of authorization remains server-side.
Next.js Server and Client Component Data Boundary
Working case
A city permit page shows 47 cases for reviewer 81. The page fetches the authorized list on the server, but a developer marks the whole page as a Client Component to support one search box. That pulls a large component subtree into the browser bundle and tempts a second client fetch. Another developer passes the complete permit service object as a prop, which cannot be serialized and contains methods and private fields. The repair leaves the page server-rendered, narrows the response to IDs, titles, and statuses the reviewer may see, and places only the filter control and rows in a Client Component. The server still checks access to detail and mutation routes separately.
Implementation boundary
import 'server-only';
import PermitFilter from './PermitFilter';
import { requireReviewer, permitRepository } from './services';
export default async function PermitPage() {
const reviewer = await requireReviewer();
const permitted = await permitRepository.firstPage(reviewer.id, 47);
const summaries = permitted.map(permitCase => ({
id: permitCase.id,
title: permitCase.title,
status: permitCase.status
}));
return <PermitFilter initialCases={summaries} />;
}Draw the boundary around the smallest interactive unit. The server page obtains reviewer identity, queries permitted cases, and maps each case to a plain transfer shape. Do not send secrets, database clients, raw errors, or records beyond the authorized first page. Put the search input and its local phrase inside a client component, then derive visible rows from those transferred cases. If the list may contain 47,000 records, return a bounded page and query the server for more rather than serializing the whole corpus. A client component may render a server component as a child through composition, but that does not grant client code direct access to server-only modules. Inspect build output and response payloads after changing a boundary.
Cost and boundaries
A server component removes its module code from the browser bundle, yet its rendered content and selected props still consume response bytes. A client island adds hydration JavaScript and memory for its state. A 47-case first page is cheap to transfer; 47,000 full records may dominate time to first useful interaction even when the server query is fast. Filtering n transferred cases is O(n) per changed phrase and may allocate O(n) results. Overly large client boundaries expand the shipped dependency graph. Overly fragmented boundaries can complicate prop contracts. Measure browser bytes, server latency, serialization size, and input response before changing the split.
Failure trace
Inspect the HTML and server component payload for an unauthorized case ID and a server secret. Confirm neither appears. Switch from reviewer 81 to reviewer 47 on the same server worker and verify their lists do not mix. Remove JavaScript and ensure the first permitted page remains readable. Check that the search box hydrates from the same initial cases the server rendered. Insert a browser-only date formatter and deliberately force a first-render mismatch, then replace it with a stable server display value or delayed personalization. Compare bundle size before and after moving the search leaf behind a client boundary.
Verification
- Only authorized plain fields cross into the browser payload.
- A small client component owns the search interaction.
- Two reviewer requests cannot share private case data.
Practice drill
Build a permit index that returns 47 authorized summaries for reviewer 81. Keep identity and repository calls server-side. Define a transfer type containing only ID, title, and status, pass it to a client filter, and display a count derived from the local phrase. Scan the output payload for an extra private field. Add a second reviewer whose case set is disjoint and run concurrent requests. Change the search leaf into a whole-page client boundary as a negative experiment, record added browser bytes and duplicate fetches, then restore the smaller boundary.
Decision note
Fetch and authorize on the server; move only the interaction and its minimal serializable data across the client boundary.
Common Mistakes
- Marking an entire server page client-side for one input.
- Passing a service object or secret as a client prop.
- Equating server execution with automatic response privacy.
Related lessons
Next.js App Router Delivery Boundaries; Next.js Cache Scope and Private Read Invalidation; Next.js Server Functions, Validation, and Operation Identity; Next.js Streaming, Suspense, and Error Recovery; React State and Rendering Boundaries; SvelteKit Request-Scoped Load and Hydration State.
Apply and check
Build Project: Next.js permit delivery and cache boundary and review Web Development: Next.js App Router boundaries quiz.
