RSC Poisoning in Next.js: How One 'use client' Doubles Your Client Bundle
By Arash Latifi
One misplaced 'use client' pulls its entire import tree to the browser: 2× bundle, slower hydration, server imports leaked. Boundary patterns, before/after code, and real benchmarks.
TL;DR: Putting
'use client'at the top of a file drags everything in that file to the browser — evendate-fns,zod, or your DB client. Fix: keep the file server-side, extract only the clickable/interactive bit into a tiny*Shell.tsxclient leaf. That's it. ~28% smaller bundle, same UI.
You run next build and... no errors, but your bundle got fat for no reason:
Route (app) Size First Load JS
┌ ○ / 178 kB 412 kB
├ ○ /dashboard 342 kB 576 kB
└ ○ /blog/[slug] 298 kB 532 kB
+ First Load JS shared by all 87.5 kB
Last week /dashboard was 198 kB. What changed? You added an onClick to Card and slapped 'use client' at the top of the file "just to make it work." Now date-fns, zod, and your lib/db.ts are all in the browser bundle. One line poisoned the whole route.
The Kitchen Analogy — Get This and You Get RSC
Think of your Next.js app like a restaurant:
- Server Components = the kitchen. They can use the oven, the fridge, the knives (DB, filesystem, heavy libraries). The customer never sees the kitchen — they just get the finished plate (HTML).
- Client Components (
'use client') = the dining table. Only what's on the table ships to the browser as JavaScript. The customer can interact with it — click, type, open a modal.
The rule: when you write 'use client' at the top of a file, you're telling Next.js "bring this entire file and everything it imports to the dining table." Not just the onClick — everything. Even that import { db } from '@/lib/db' you forgot to remove. Even date-fns you only used for formatting.
That's why we call it poisoning — one 'use client' infects the whole import tree:
[Server] page.tsx ──import──▶ [Client] Card.tsx ──import──▶ date-fns (18 kB gz)
├──▶ zod (12 kB gz)
└──▶ lib/format.ts ──▶ heavy-utils (??)
Result: every page that uses <Card> pays 30+ kB gz — even <Card title="x" />
You just wanted useState for an isOpen toggle. But because zod lives in the same file, it comes along for the ride. The bundler can't cherry-pick — the whole file is now client code.
The Fix — Keep the Kitchen Work in the Kitchen
Don't make the whole Card a Client Component. Keep the heavy work (formatting, validation) on the server and extract only the interactive shell.
1) Poisoned vs. Healthy — The Same Card, Done Right
Before — poisoned (everything goes to the browser):
After — only the clickable shell is client:
Same onClick, but date-fns never leaves the server. The formatted string travels as a simple prop.
Kitchen translation: Before, you brought the whole kitchen (oven + fridge) to the table just because the customer wanted a fork. After, you just send the plated food + a fork.
2) Composition Trick — Let the Server Render the Children
If a Client Component wraps content, don't let it create the content. Pass children from the server parent:
The children rendered on the server streams as HTML — no manual serialization needed.
3) Can't Pass Functions? Use Server Actions
Passing onSelect={() => ...} from server to client fails — functions aren't serializable. Use a Server Action instead:
4) Heavy Stuff? Lazy-Load It
If you really need framer-motion or recharts, don't ship it on first load:
4 Traps That Bite You at 2 AM
1. The poisoned barrel file. You have app/ui/index.ts doing export * from './Card' and export * from './Button'. If one of them has 'use client' and you import { Card } from '@/app/ui' in a Server Component, the whole barrel can be treated as client.
Fix: skip the barrel, import directly — import { Card } from '@/app/ui/Card'.
2. "Isomorphic" libs that aren't. date-fns, zod, clsx look small alone but cost 80–120 kB gz together. Worse, moment, full lodash, prisma/client on the client will blow up the bundle or break the build (fs is not defined).
Fix: add the server-only package so leaks fail the build:
3. Non-serializable props. Passing Date, Map, Set, or functions from server to client throws: Functions cannot be passed directly to Client Components.
Fix: Date → string (ISO), Map/Set → arrays, functions → Server Actions.
4. The cascading 'use client'. You make Layout.tsx client for usePathname(), and suddenly the whole layout + children are client.
Fix: extract the usePathname logic into a tiny ActiveLink.tsx leaf and keep the layout server.
Does It Actually Help? Real Numbers
Tested on a real app (Next.js 15.3, App Router, /dashboard with 12 cards):
| Scenario | First Load JS (gz) | /dashboard JS | TTI on 4G |
|---|---|---|---|
| Poisoned — Card.tsx with 'use client' + date-fns + zod | 412 kB | 576 kB | ~3.4s |
| Healthy — Card server + CardShell leaf client | 298 kB | 412 kB | ~2.1s |
| Healthy + dynamic for chart | 274 kB | 388 kB | ~1.9s |
That's ~28% off First Load JS just by moving the boundary. For a 500k visits/month site, that's 40–60 GB less monthly transfer on Vercel — straight off your bandwidth bill.
Quick Decision Cheat Sheet
| What you need | Where to put it | Why |
|---|---|---|
| useState, useEffect, onClick, usePathname | tiny client leaf (*Shell.tsx) | minimal JS |
| Date formatting, zod validation, DB queries | server — pass result as string/JSON | zero JS |
| Heavy animation / chart | client + dynamic + ssr:false | lazy loaded |
| Entire page.tsx or layout.tsx | never unless forced | poisons the whole route |
Copy-Paste Checklist — Do This Today
- Measure now:
npm run build→ note Route Sizes. Runnpx @next/bundle-analyzeronce. - Find boundaries:
grep -r "'use client'" app --include="*.tsx" | head -20— any file with'use client'plus heavy imports is suspect. - Apply the Shell pattern: for each poisoned component, make a 10–20 line
*Shell.tsxclient file; keep the heavy logic in the server parent (Card→CardShell). - Install
server-onlyonlib/db.ts,lib/auth.ts— so any leak fails the build. - Fix props:
Date→string,Map→ array, function → Server Action.npm run buildwill tell you if you missed one. - Re-measure and gate it: log
First Load JSin CI, block PRs that blow past the threshold (bundlesizeorsize-limit).
When NOT to Bother
- Legacy
pages/router SPA? This model doesn't apply — everything is client anyway. - Component is 100% interactive (e.g., a Canvas Editor with tons of
useState/useEffect)? Don't half-server it — just keep it as one clean client file. - Library is client-only and used everywhere (e.g.,
framer-motionon every page)? Moving the boundary won't save you — usedynamicand code-splitting instead.
Boundary Review Checklist
- [ ]
npm run buildRoute Sizes recorded as the baseline, before touching boundaries - [ ]
grep -rn "'use client'" app --include="*.tsx"reviewed; every hit audited for heavy imports - [ ] Each interactive leaf extracted into a small
*Shell.tsx; heavy logic stays in the server parent - [ ]
server-onlyimported at the top oflib/db.tsandlib/auth.ts - [ ] No non-serializable props crossing the boundary (
Dateas string,Map/Setas array, callbacks as Server Actions) - [ ] Barrel imports replaced with direct file imports on server components
- [ ]
First Load JSlogged in CI and asize-limit/bundlesizebudget enforced on pull requests
FAQ
Q: Do I need 'use client' at the top of every file that has a client component?
No. The directive only marks entry points — the files whose components you want to render directly inside a Server Component. Everything those entry points import becomes client code.
Q: Why does passing a function from a Server Component fail?
Functions are not serializable across the server/client boundary, so onSelect={() => ...} throws. Move that work into a Server Action and call it from the client leaf instead.
Q: Is a barrel file really that dangerous?
It can be. If a barrel re-exports a module that has 'use client', importing anything from that barrel in a Server Component can pull the whole set of modules into the client graph. Import the specific file directly.
For the data layer side of this split, see Next.js 15 Caching Deep Dive — how use cache and PPR bring the same server/client logic to caching.
Real-world example? Check /projects — most are built with this exact boundary to stay under 300 kB.
I'm Arash Latifi — if your bundle is bloated or hydration is slow, get in touch and we'll fix your boundaries together.
For more deep dives, browse the full tech archive.