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:
Route (app) Size First Load JS
┌ ○ /pricing 24.1 kB 142 kB
# after adding 6 icons:
┌ ○ /pricing 68.4 kB 187 kB ← +44 kB gzipped 😬
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 run:
grep -rl "use client" node_modules/<lib>— a lib where every icon matches is out for server pages - [ ] Server icons installed (
pnpm add @geoicons/react) or raw SVG copied intocomponents/icons/*.tsxwith no'use client' - [ ]
app/pricing/page.tsxconfirmed to have no'use client'of its own, and icons imported directly - [ ]
IconProviderisolated inapp/layout.tsx, never in the same file as the icons - [ ] Build verified:
pnpm build && grep -r "lucide\|react-icons" .next/static/chunks/comes back empty - [ ] CI monitors
du -sh .next/static/chunks/*.js | sort -hand blocks PRs that grow First Load JS by more than 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.
FAQ
Q: How do I tell whether an icon library is safe for Server Components?
Grep its distribution for a top-of-file 'use client': grep -rl "^['\"]use client['\"]" node_modules/<pkg>. A library whose icon files all match will turn any page that imports it into a client page.
Q: Can I keep using lucide-react on server components?
Yes, as long as the individual module you import has no 'use client' of its own. Import the specific icon file rather than the barrel, and if even that file is marked, copy the SVG path into your own component.
Q: Why does the SVG path show up twice in the page HTML?
Because Next.js sends the RSC payload in addition to the HTML, and that payload repeats the path data. What matters is that the icon code is absent from /_next/static/chunks/*.js, which is what your users download as JavaScript.
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.