Where to Draw the Line: Managing State Across the Next.js Server/Client Boundary
Putting 'use client' on a page to silence a build error quietly reverts your route to a client-rendered SPA. Here's how to place the server/client boundary deliberately, with a worked filtering example and the composition pattern.
14 Sept 2025, 02:27 UTC

Every Next.js App Router project eventually hits the same wall: you add useState to a component, the build fails because that component is a Server Component, and the tempting fix is to slap 'use client' at the top of the file. Do that at the page level and you have quietly converted your route back into a client-rendered SPA, losing most of what React Server Components (RSC) give you. The better skill is learning where the boundary actually belongs — usually much lower in the tree than you first assume.
The thesis of this post: treat 'use client' as a scalpel, not a bandage. Push interactivity down to the leaves of your component tree, pass only serializable data across the boundary, and use composition when a client wrapper needs server-rendered content inside it.
What the boundary actually is
In the App Router, every component is a Server Component by default. Server Components render on the server only — their code never ships to the browser. That means they can be async, query a database directly, and read secrets from environment variables, all without adding a byte to your client bundle.
The moment you need interactivity — useState, useEffect, event handlers, browser APIs — you need a Client Component, marked with the 'use client' directive at the top of the file. The directive doesn't mean "render this on the client only"; it means "this module, and everything it imports, is part of the client bundle." Client Components still get server-rendered for the initial HTML, but their code is also shipped for hydration.
The critical constraint: whatever you pass from a Server Component into a Client Component as props must be serializable — strings, numbers, booleans, plain objects, arrays, Dates in supported versions. Functions, class instances, and Maps will throw at runtime. This trips people up most when they try to pass a callback like onClick={someServerFunction} down to a client child. (Server Actions are the sanctioned exception, but that's a separate topic.)
A worked example: product list with a client-side filter
Say you have a page that lists products from a database, and you want a search box that filters them client-side. The naive version puts 'use client' on the page and fetches in an effect. The RSC version splits it:
// app/products/page.tsx — Server Component (no directive needed)
import { db } from '@/lib/db';
import { ProductFilter } from './ProductFilter';
export default async function ProductsPage() {
const products = await db.product.findMany({
select: { id: true, name: true, price: true },
});
// products must be plain serializable objects —
// avoid passing ORM instances with methods attached.
return (
<main>
<h1>Products</h1>
<ProductFilter products={products} />
</main>
);
}// app/products/ProductFilter.tsx
'use client';
import { useState } from 'react';
type Product = { id: string; name: string; price: number };
export function ProductFilter({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const visible = products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter products…"
/>
<ul>
{visible.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</>
);
}The page stays on the server: the database call, any secrets, and the data-fetching logic never reach the browser. Only ProductFilter and its dependencies ship as client JavaScript. You can verify this yourself — build the app (next build), then check the route's "First Load JS" in the build output, or open DevTools' Network tab and confirm the filtering logic is a small chunk rather than the whole page.
Composition: when a client component needs server content inside it
A common dead end: you have a client wrapper — say, a collapsible sidebar with open/close state — and you want server-rendered content inside it. You cannot import a Server Component into a Client Component; the import pulls it into the client bundle. The escape hatch is composition: pass the server content in as children (or another prop) from a Server Component parent.
// app/layout-fragment.tsx — Server Component
import { Collapsible } from './Collapsible'; // client
import { ServerRenderedNav } from './ServerRenderedNav'; // server
export function SidebarSection() {
return (
<Collapsible label="Navigation">
<ServerRenderedNav />
</Collapsible>
);
}Because ServerRenderedNav is rendered by the server parent and passed down as an already-rendered slot, it stays a Server Component. Collapsible just receives children as opaque rendered output. This pattern is the single most useful tool for keeping 'use client' from creeping up your tree.
The trade-offs worth knowing
This model isn't free. First, the serializability rule means you sometimes reshape data at the boundary — formatting dates to strings, flattening ORM results — which is boilerplate you didn't have in a pure client app. Second, every client boundary creates a hydration point, and a deeply nested tree of alternating server and client components can be harder to reason about than either extreme. Third, client-side filtering like the example above only makes sense for modest lists; if you're shipping 10,000 products to the browser to filter, the bundle and memory costs dwarf the RSC savings, and you should move filtering back to the server via search params instead.
Behavior described here reflects the stable App Router in Next.js 13.4 through the 14/15 era; check your version's docs if you're on a canary or an older pages router setup, where none of this applies.
What to do on Monday
Audit one route in your app: find every file with 'use client' and ask whether the state it holds could live one level lower. If a whole page is a client component just because one button needs a handler, extract the button. If a client wrapper imports server-rendered content, convert it to accept children. Then run next build and compare the route's First Load JS before and after — that number is the most honest scorecard for whether your boundaries are in the right place.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.