React Icons: 12 From a Library, 55 Written by Hand
One icon library, twelve imports, 55 inline SVGs — and the split falls exactly on the client boundary rather than on taste.
This codebase ships one icon library and imports twelve icons from it. It also contains 55 hand-written <svg> elements. That ratio is not indecision — the twelve come from lucide-react and every one of them lives in a "use client" component, while 50 of the 55 inline SVGs sit in Server Components and data files that never reach the browser as JavaScript at all. The client boundary, not taste, is what decides which kind of icon you get.
The short answer
A React icon library gives you every icon as a component, tree-shaken to the ones you import. Inline SVG gives you markup with no dependency and no client cost. Use the library where an icon changes with state — a spinner, a toggle, a chevron — and inline SVG for static decoration, which in a Server Component ships as plain HTML.
What this codebase actually imports
Eleven files import from lucide-react. Here is all of it:
| File | Boundary | Icons |
|---|---|---|
molecules/PreviewModal.tsx | client | ExternalLink, Loader2, Monitor, Smartphone, Tablet, X |
molecules/DownloadMenu.tsx | client | ChevronDown, Download, DownloadCloud |
molecules/SavedTemplates.tsx | client | Heart, X |
molecules/PurchaseCta.tsx | client | Download, Loader2 |
molecules/TemplateCard.tsx | client | Eye |
molecules/UseCaseCard.tsx | client | Eye |
molecules/EditionPicker.tsx | client | Eye |
molecules/ProductDownloadGroup.tsx | client | Eye |
molecules/WishlistButton.tsx | client | Heart |
molecules/BuyButton.tsx | client | Loader2 |
organisms/YourProductsGrid.tsx | client | Search |
Twelve distinct icons, 21 render sites, 11 files — and the "client" column has no exceptions in it. lucide-react is one of thirteen runtime dependencies in package.json, alongside next, react, react-dom, the two Supabase packages, MDX, and @vercel/analytics. It is also the only one of the thirteen that renders UI, which is the subject of why this site ships no component library.
Read the icon names and the pattern is obvious. Loader2 spins while a checkout request is in flight. Heart fills when a template is saved. ChevronDown rotates when a menu opens. Monitor/Tablet/Smartphone are the device toggle inside the preview modal. X closes things. Every one of them is attached to something the user did.
The 55 that were written by hand
| Location | <svg> elements | Boundary |
|---|---|---|
src/data/features.tsx | 14 | server |
src/data/heroTech.tsx | 7 | server |
src/data/footer.tsx | 6 | server |
src/data/techStack.tsx | 5 | server |
organisms/Trust.tsx | 3 | server |
organisms/Testimonials.tsx | 3 | server |
molecules/TechStackCard.tsx | 3 | server |
organisms/PaymentInfo.tsx | 2 | server |
organisms/Footer.tsx | 2 | server |
organisms/DocsContent.tsx | 2 | server |
molecules/TemplateGallery.tsx | 2 | client |
organisms/UseCases.tsx, molecules/HeroBadge.tsx, molecules/BlogCard.tsx | 1 each | server |
organisms/Header.tsx, molecules/FaqItem.tsx, molecules/AuthCard.tsx | 1 each | client |
50 server, 5 client. The single largest concentration is src/data/features.tsx, where fourteen SVGs are stored as JSX inside a typed content array — which is why that file and its three siblings are .tsx rather than .ts. They are content, not components: a section's copy and its icon travel together, and the organism that maps over the array renders both.
These are not icons a library would have. They are brand marks (GitHub, Figma, the payment-provider logos in PaymentInfo), the multi-stop linearGradient decorations in Footer, and one-off arrow glyphs with per-element Tailwind transitions baked into their className:
// src/components/organisms/Trust.tsx
<svg
className="absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ease-in-out group-hover:translate-x-5 group-hover:-translate-y-5"
fill="none"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
That group-hover:translate-x-5 group-hover:-translate-y-5 is the whole argument for inline SVG in one line. The icon is not a self-contained thing being placed on the page; it is part of a hover animation defined on an ancestor. Wrapping it in a library component means passing className through and hoping the library forwards it.
Why the split falls exactly on the client boundary
An icon imported from lucide-react is a React component. A React component used in a Client Component becomes part of that route's JavaScript bundle. An inline <svg> inside a Server Component is rendered to HTML during next build and arrives as markup — the browser parses it as it would any other element, and no JavaScript is involved in putting it on screen.
This site prerenders 584 pages. For the vast majority of the icons on them — the fourteen feature glyphs on the home page, the five tech-stack logos, the six footer marks — there is nothing for a library to do that HTML does not already do for free. The library earns its place at exactly the 21 sites where the icon has to change after the page loads, and those 21 sites are already inside a "use client" component for other reasons: they own a spinner's pending state, a modal's open state, a wishlist toggle.
So the rule that produced these numbers is not "prefer inline SVG" or "prefer a library". It is: the icon lives wherever its state lives. Static icon in a Server Component → inline SVG. Stateful icon in a Client Component → import it, because you are paying for the bundle anyway.
The casing rule that compiles in dev and fails the build
Copy an SVG out of Figma or a design file and paste it into JSX and you get HTML-cased attributes. JSX wants the DOM property names:
// src/components/organisms/UseCases.tsx — correct
<path strokeLinecap="round" strokeWidth="1.5" />
// src/components/organisms/Footer.tsx — correct
<feGaussianBlur stdDeviation="1.25555"></feGaussianBlur>
strokeWidth, fillRule, clipPath, linearGradient, feGaussianBlur — all camelCase in JSX. The attribute half of this is loud: React warns in the console. The element half is silent and expensive. A lowercase SVG element name like <clippath> or <lineargradient> is valid-looking JSX, so next dev renders it (as an unknown host element that does nothing visible), and it is next build — which type-checks every component — that finally rejects it. The failure lands minutes later, in CI, on a paste that looked fine locally.
This is enforced by convention in CLAUDE.md rather than by a lint rule, because the thing it catches is a paste, and pastes arrive faster than rules do. If you are hand-rolling 55 SVGs, put the camelCase rule where whoever pastes the 56th will read it.
Library, inline, or sprite
| Approach | Ships JS | Per-icon cost | Good for |
|---|---|---|---|
Icon library (lucide-react, react-icons) | Yes, into the route bundle | One component per import, tree-shaken | Stateful icons; large UIs needing one consistent grid and stroke |
Inline <svg> in a Server Component | No | Bytes of HTML, once, at build time | Static decoration, brand marks, anything animated by an ancestor's group-hover |
SVG sprite (<use href="#id">) | No | One request, cached across pages | Dozens of repeats of the same handful of glyphs |
<img src="icon.svg"> | No | One request per icon | Icons that never need currentColor or CSS control |
The last row is the one to be careful with. An <img> pointing at an .svg file cannot inherit currentColor and cannot be styled from CSS at all, which rules it out for icons but is exactly right for photographic content — this codebase reaches for plain <img> 26 times for product screenshots and their build-time WebP variants, and never for an icon.
The sprite row is the one this codebase does not use and could. With 55 inline SVGs across 17 files there is some duplication — the same arrow glyph appears in more than one section. It has not been worth a build step, because these are prerendered pages where the markup is gzipped once and served from a CDN, but on a site with client-side navigation between many pages the sprite's cross-page cache would matter more.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
next build fails on an SVG that renders fine in dev | A lowercase SVG element name (<clippath>, <lineargradient>) — dev treats it as an unknown host element, the build type-checks it | Convert every element and attribute to JSX casing: <clipPath>, <linearGradient>, strokeWidth, fillRule |
Icon ignores text-* color utilities | The SVG hardcodes fill="#000" instead of inheriting | Use fill="currentColor" (or stroke="currentColor") and set the color with a Tailwind class on the icon or its parent |
| Importing one icon pulls in a large chunk | A barrel import (import * as Icons) or a library published without ESM/sideEffects: false | Import named icons only (import { Eye } from "lucide-react"), and check the library ships ES modules |
| Icon appears in the accessibility tree as unlabeled graphic | Decorative SVG with no aria-hidden | Add aria-hidden="true" for decoration; give meaningful icons a <title> or an aria-label on the interactive parent |
| Icon is a client component in an otherwise server page | The library component was imported into a Server Component that had no other reason to be client | Inline the SVG instead, or move just the interactive part behind its own "use client" boundary |
| Icon renders at the wrong size | No explicit height/width class; the SVG's intrinsic size wins | Set h-4 w-4 (or the library's size prop) — never rely on the artboard the icon was exported at |
Frequently asked questions
Is an icon library slower than inline SVG?
Per icon, at render time, no — both end up as the same <svg> in the DOM. The difference is delivery. A library icon is JavaScript that must be downloaded, parsed and executed before it paints; an inline SVG in a Server Component is already in the HTML. On a static page with no interactive icons, the library is pure overhead.
Do I need react-icons if I already use one icon set?
react-icons bundles many sets behind one dependency, which is useful when you genuinely need glyphs from several families. If one set covers you, a single-family package imports more directly and gives you one grid and one stroke width by construction. This codebase needed twelve glyphs and took one package.
How do I make an icon accessible?
Decide whether it carries meaning. A chevron next to a visible "Download" label carries none — mark it aria-hidden="true" so a screen reader reads the label once, not twice. An icon that is the control (a bare X close button) needs an accessible name on the button, not on the SVG: aria-label="Close".
Can I use icon components in a Server Component?
Yes, if the library's components are plain function components with no hooks or event handlers — they render to HTML on the server like anything else. The catch is that they still arrive as an import in your module graph, and one stray onClick in the same file flips the whole file to client. Inline SVG has no such cliff, which is why the static 50 here are inline.
Templates in this post
ASoc Nexus, ASoc Nimbus and ASoc Nova are Next.js + Tailwind landing page templates built on the same split audited above — inline SVG for the static section art, the icon library only where an icon reacts to a click.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
