SVG Icons in RSC: How One Import Bloats Your Client Bundle and How to Ship Zero JS
By Arash Latifi
Many icon libraries silently turn your page into a client tree with a hidden 'use client'. The 5-second grep test, zero-JS server rendering, and 44kB vs 0kB benchmarks — with copy-paste code.
TL;DR: You add 6 icons, your bundle jumps 44 kB, and you never wrote
'use client'. Many icon libs ship with'use client'baked in, so importing even one icon drags your whole page into the client bundle. Fix:grepyournode_modulesfor'use client', switch to server-friendly icons (or raw SVG), and verify with a build check. Takes 10 minutes, saves real load time.
You add 6 icons to /pricing. Build passes, no errors. Then you run next build and see this:
You didn't add any 'use client'. So what happened?
Open DevTools → Sources → _next/static/chunks and you'll find it: lucide-react/dist/... or all of react-icons/fa sitting in your client JS. For what? A few static SVG drawings. On 3G that's an extra 280ms before the page becomes interactive — just for icons.
This is exactly what Rushan broke down yesterday at GeoIcons: some icon libraries play nice with Server Components, some don't. And you can find out which in 5 seconds.
The One Rule That Matters
RSC has one simple rule:
- No
'use client'at the top → Server Component. Renders on the server, sends plain HTML/SVG to the browser. Zero JS. 'use client'at the top → Client Component. That file and everything it imports must be sent to the browser as JavaScript.
Here's the catch with icons: a lot of popular libraries put 'use client' in their barrel file (index.ts that re-exports every icon) or their icons use forwardRef/useState under the hood. So when you write:
...you think you're getting one icon. You're actually pulling the whole library into the client bundle. Tree-shaking can't save you — the client boundary is decided before bundling even starts.
And here's the sneaky part the Next.js docs don't emphasize: the boundary spreads upward. One client leaf makes its parent a client component too. "It's just one icon" doesn't matter.
Quick Check: Is Your Library Guilty?
| Library | grep -rl "^['\"]use client['\"]" node_modules/<pkg> | What it means |
|---|---|---|
| @geoicons/react | Only dist/_license.js | Icons are server — zero JS ✅ |
| lucide-react@0.469 (barrel) | dist/lucide-react.js + every icon | Barrel import = whole page goes client ❌ |
| react-icons | Every fa/*.js | Basically always client ❌ |
Think of it like this:
app/page.tsx (Server)→import { Nz } from "@geoicons/react"→ no boundary → Next.js just puts<svg><path d="m6.35..." /></svg>in the HTML. Done.app/page.tsx→import { Search } from "lucide-react"(barrel, client) →page.tsxitself becomes client, even though you never wrote'use client'there.
The Fix: From 44 kB to Zero
Step 1: The 5-Second Poison Test
Run this before you change anything:
Pro tip: Keep the
^at the start of the regex. Without it, you'll get 799 false positives from files that just mention "why we avoid use client" in a comment.
Step 2: Before → After
Before — the sneaky barrel import that poisons your page:
After — server icons, zero JS:
Why does it work? Look at the icon source — there's no 'use client':
Step 3: "But I Want to Keep lucide"
Totally fine — just avoid the barrel:
If even the individual files have 'use client', the simplest escape is to just copy the SVG. One-time job, zero dependencies forever:
Step 4: Make Sure It Actually Worked
After pnpm build, check the client chunks:
Gotchas That Bite at 2 AM
-
optimizePackageImportsdoesn't fix the boundary. It helps with tree-shaking, but if the file you import itself has'use client', you're still client. Grep first, optimize second. -
IconProvideris the only thing that should be client. In GeoIcons, onlyIconProvider(for theming) usesuseState/useEffectand has'use client'. Icons don't depend on it, so put the provider inapp/layout.tsxand keep everything else server. Don't put provider and icons in the same file or the whole file becomes client. -
Seeing the path twice in HTML is normal. Even with server icons, Next.js sends RSC flight data that repeats the
d="m6.35..."path. You'll see<pathtwice if you grep the HTML. That's fine — what matters is it's not in/_next/static/chunks/*.js. -
useIdtrick. GeoIcons usesuseIdfor uniqueclipPathids. It works in Server Components (React 19), but if you render that icon inside a Client Component, the server/client ids won't match and you'll get a hydration warning. Keep server icons inside Server Components, or passidmanually.
Is It Worth It? The Numbers
Next.js 15.1, /pricing with 6 icons, gzip -9:
| Scenario | First Load JS (gz) | Icon overhead | Extra HTML | TTI on 3G | |---|---|---|---|---| | No icons (baseline) | 87.5 kB | — | — | 1.9s | | 6× lucide-react (barrel) | 131.8 kB | +44.3 kB | ~2 kB | 2.18s | | 6× lucide (direct import) | 102.1 kB | +14.6 kB | ~2 kB | 2.02s | | 6× GeoIcons (server) | 87.9 kB | +0.4 kB | +8.1 kB | 1.91s | | 6× raw SVG (copied path) | 87.6 kB | +0.1 kB | +8.0 kB | 1.90s |
In plain English: barrel imports cost you ~280ms on 3G. Vercel's data says every 100ms delay ≈ 1% conversion drop on e-commerce. That 280ms could be 2–3% less revenue. On a $10k/mo store, that's $200–300/mo lost to icons.
So What Should You Use?
| Situation | Go with | Why |
|---|---|---|
| Static icons (pricing, footer, feature list) | Server SVG / GeoIcons | Zero JS, fastest |
| Animated/interactive icon (like button) | Separate 'use client' wrapper just for that icon | Keep the rest of the page server |
| 50+ icons on one page | SVG sprite or icon font | Too many paths bloat HTML otherwise |
| Need dynamic theming (dark/light) | GeoIcons + IconProvider in layout | Provider is client, icons stay server — best of both |
Checklist: Ship It in 6 Steps
- Poison test:
grep -rl "^['\"]use client['\"]" node_modules/<lib>— if every icon matches, don't use that lib on server pages. - Install server icons:
pnpm add @geoicons/reactor copy raw SVGs tocomponents/icons/*.tsx(no'use client'). - Keep the page server: Make sure
app/pricing/page.tsxhas no'use client'. Import icons directly. - Isolate the provider (if you need theming):
- Verify:
pnpm build && grep -r "lucide\|react-icons" .next/static/chunks/— should be empty. - Monitor in CI: Log
du -sh .next/static/chunks/*.js | sort -hand block PRs that grow First Load JS by >5 kB.
When Not to Bother
- Truly interactive icons — if it morphs on hover, animates with
framer-motion, or has internal state, it needs to be client. Just isolate it:components/icons/AnimatedCheck.tsxwith'use client'on that file only. - 200+ icons on one page (like a gallery) — 200 inline SVGs = ~300 kB HTML, slow to parse on mobile. Use a sprite sheet instead.
- CSS-in-JS icon packs (like
phosphor-react) — they inject styles with JS and break without it. They're client by nature. Switch libs or accept the cost.
Want the deep dive? See the full 'use client' boundary breakdown at /tech/rsc-poisoning-use-client-boundary-tech. Real-world examples at /projects — or reach out via /about for perf help.
Sources: GeoIcons — SVG icons in RSC (2026-08-31), Next.js Docs — use client, local bundle test on Next.js 15.1.