In traditional Single Page Application (SPA) architectures and classic Server-Side Rendering (Next.js Pages Router), a fundamental bottleneck persisted: the entire JavaScript payload of components rendered on the server had to be shipped to the client browser for hydration.
As codebases scale, client bundle size expands exponentially. Browsers are forced to download, parse, and compile megabytes of JavaScript even for completely static interface regions: navigation headers, footers, article markup, as well as heavyweight server utilities (Markdown parsers, syntax highlighters, date formatters, mathematical libraries). This severely impairs Core Web Vitals—chiefly TTI and INP.
A secondary bottleneck is Network Waterfalls: a client component mounts, initiates a network fetch, waits for an API response, and then mounts a child component that fires its own subsequent network request.
React Server Components (RSC) in Next.js App Router dismantle these constraints:
In Next.js App Router, the runtime environment is split into two distinct yet collaborative domains: the Server Module Graph and the Client Module Graph.
By default, all components are Server Components. No special syntax or directives are needed to enable server execution. The boundary separating these two graphs is the Network Boundary, established using the 'use client' directive.
Inspect React Flight serialization boundaries and understand 'use client' bundle impact
The 'use client' directive is pushed down to the leaf node (button). Heavy parsers and parent data execute exclusively on the server and never leak into the client JS bundle.
Crucially, a component marked with 'use client' still renders on the server during the initial request to produce static HTML. The 'use client' directive does not mean "render only in the browser"; rather, it instructs the bundler to "include this module and its subtree imports in the client JavaScript bundle for subsequent hydration."
The server component tree is represented over the wire via the React Flight Payload. Instead of rendering interactive client components on the server, the stream emits a Client Reference.
The user requests a route. Next.js initiates server-side evaluation of route segment trees (Layouts, Pages).
The server executes asynchronous components, fetching data from databases and private APIs in parallel.
React serializes the component tree: server elements become data structures, while client elements become Client References with serialized props.
The server immediately streams initial HTML for Fast First Paint, concurrently streaming RSC payload chunks and Suspense boundaries.
The browser parses HTML, fetches minimal JS chunks for 'use client' components, and selectively hydrates interactive islands without re-rendering server trees.
Does placing 'use client' at the top of a file cause the component to compile and execute exclusively in the browser?
Server Components are declared as standard React functions. Their key capability is native support for async/await directly in the component body.
In Next.js 15+, route parameters (params and searchParams) are typed as Promise and must be explicitly awaited.
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function UserPage({ params, searchParams }: PageProps) {
// In Next.js 15+, access to params and searchParams is asynchronous
const { id } = await params;
const query = await searchParams;
const activeTab = typeof query.tab === 'string' ? query.tab : 'overview';
// Direct database query without intermediary REST API endpoints
const user = await db.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, role: true }
});
if (!user) {
notFound();
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-text-primary">{user.name}</h1>
<p className="text-text-muted">{user.email}</p>
<div className="mt-4 text-xs font-mono text-accent-cyan">
Active tab: {activeTab}
</div>
</div>
);
}'use client')#The 'use client' directive must sit at the very top of the file before any imports or comments.
It is strictly required only when a component relies on:
useState, useReducer, useEffect, useLayoutEffect.onClick, onChange, onSubmit, onKeyDown.window, document, localStorage, navigator, WebSockets.'use client';
import { useState, useTransition } from 'react';
interface CounterProps {
initialCount?: number;
}
export function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const [isPending, startTransition] = useTransition();
const handleIncrement = () => {
startTransition(() => {
setCount((prev) => prev + 1);
});
};
return (
<div className="flex items-center gap-4">
<button
type="button"
disabled={isPending}
onClick={handleIncrement}
className="px-4 py-2 rounded-lg bg-accent-violet hover:bg-accent-violet/90 text-white font-medium transition-colors disabled:opacity-50"
>
Increment: {count}
</button>
{isPending && <span className="text-xs text-text-muted">Updating...</span>}
</div>
);
}'use client' vs 'use server'#A frequent source of architectural confusion is assuming that 'use server' marks a component as a Server Component. It does not:
'use client': Marks a module as a Client Boundary.'use server': Declares a Server Action—a server-side RPC function for mutating data invoked via HTTP POST. It never creates a server UI component!When an interactive client component (such as a modal dialog, accordion, or dropdown drawer) needs to wrap a heavy server component, never import the server component directly inside the client file.
Instead, employ the Donut Pattern using children or named JSX slots:
'use client';
import { useState } from 'react';
interface ModalDialogProps {
triggerLabel: string;
children: React.ReactNode; // Server Component is passed in from outside!
}
export function ModalDialog({ triggerLabel, children }: ModalDialogProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className="px-3 py-1.5 rounded-md bg-bg-elevated border border-border text-sm"
>
{triggerLabel}
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-bg-surface border border-border rounded-xl p-6 max-w-lg w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold">Details</h2>
<button type="button" onClick={() => setIsOpen(false)}>✕</button>
</div>
{/* Server slot renders here, remaining purely server-side code */}
{children}
</div>
</div>
)}
</>
);
}import { ModalDialog } from '@/components/ModalDialog';
import { HeavyAnalyticsChart } from '@/components/HeavyAnalyticsChart'; // Server Component with heavy dependencies
export default async function DashboardPage() {
return (
<main className="p-6">
<h1 className="text-xl font-bold mb-4">Analytics</h1>
{/* Donut Pattern: pass Server Component as children into Client Component */}
<ModalDialog triggerLabel="Open Report">
<HeavyAnalyticsChart />
</ModalDialog>
</main>
);
}use())#In React 19 and Next.js 15+, you can pass unresolved Promise objects across the props serialization boundary. This prevents blocking TTFB in server components while allowing client components to unwrap data via the use() hook.
import { Suspense } from 'react';
import { ItemComments } from './ItemComments';
import { fetchCommentsPromise } from '@/lib/data';
export default async function ItemPage({
params
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Initiate data fetching WITHOUT await: promise runs in background
const commentsPromise = fetchCommentsPromise(id);
return (
<div>
<h1>Product Item #{id}</h1>
{/* Wrap in Suspense to display a fallback skeleton while promise resolves */}
<Suspense fallback={<p className="text-text-muted">Loading comments...</p>}>
<ItemComments commentsPromise={commentsPromise} />
</Suspense>
</div>
);
}'use client';
import { use } from 'react';
interface Comment {
id: string;
author: string;
text: string;
}
export function ItemComments({
commentsPromise
}: {
commentsPromise: Promise<Comment[]>;
}) {
// React 19 use() hook unwraps the streaming promise directly on the client
const comments = use(commentsPromise);
return (
<div className="space-y-3 mt-4">
<h3 className="font-semibold text-text-primary">Comments ({comments.length}):</h3>
{comments.map((comment) => (
<div key={comment.id} className="p-3 rounded-lg border border-border bg-bg-elevated/40">
<span className="font-medium text-accent-cyan text-sm">{comment.author}</span>
<p className="text-sm text-text-muted mt-1">{comment.text}</p>
</div>
))}
</div>
);
}To prevent accidental client imports of server-only modules, install the server-only package:
pnpm add server-onlyTo protect sensitive database entities from leaking into client props, leverage the React 19 Taint API:
import 'server-only';
import { experimental_taintObjectReference } from 'react';
import { db } from '@/lib/db';
export async function getUserAccount(id: string) {
const user = await db.user.findUnique({
where: { id }
});
if (user) {
// If an engineer attempts to pass the full user object to a Client Component,
// React halts execution with a build-time or runtime error
experimental_taintObjectReference(
'Do not pass raw user record to Client Components to prevent password hash leakage',
user
);
}
return user;
}Which of the following data types CAN be safely passed as a prop from a Server Component to a Client Component across the React Flight boundary?
Engineers migrating from other frameworks often prepend 'use server' to UI component files expecting them to run on the server.
In App Router, all components are Server Components by default. The 'use server' directive is reserved strictly for declaring Server Actions (asynchronous RPC endpoints for data mutation via POST requests). Placing 'use server' inside a standard UI component causes a compiler error.
Querying database records and passing raw objects directly into Client Components serializes all fields—including passwordHash, stripeCustomerId, and internal keys—into the plaintext RSC Flight Payload, visible to any user in the browser Network DevTools tab.
Mitigation: Always project database queries into strict DTOs containing only the minimal public fields needed by the UI.
// ❌ ANTI-PATTERN: Full entity leaks into public RSC Flight Payload
const user = await db.user.findUnique({ where: { id } });
return <UserBadge user={user} />;
// ✅ CORRECT: Only required public attributes are serialized
return <UserBadge name={user.name} avatarUrl={user.avatarUrl} />;Attempting to import a Server Component directly into a file with 'use client' causes Turbopack to pull the server component into the client module graph. This leads to bundle bloat or build crashes when server-only libraries (db, fs, private env vars) fail in the browser. Always use the Donut Pattern (children or slots) for nesting.
Awaiting a slow asynchronous query in a top-level layout.tsx (const settings = await getSlowSettings()) delays Time to First Byte (TTFB) for the entire application, leaving users on a blank screen.
Mitigation: Push data fetching deeper into the tree and wrap slow components in <Suspense fallback={<Skeleton />}>.