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:
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:
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.
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.