Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

FileBoundaryIcons
molecules/PreviewModal.tsxclientExternalLink, Loader2, Monitor, Smartphone, Tablet, X
molecules/DownloadMenu.tsxclientChevronDown, Download, DownloadCloud
molecules/SavedTemplates.tsxclientHeart, X
molecules/PurchaseCta.tsxclientDownload, Loader2
molecules/TemplateCard.tsxclientEye
molecules/UseCaseCard.tsxclientEye
molecules/EditionPicker.tsxclientEye
molecules/ProductDownloadGroup.tsxclientEye
molecules/WishlistButton.tsxclientHeart
molecules/BuyButton.tsxclientLoader2
organisms/YourProductsGrid.tsxclientSearch

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> elementsBoundary
src/data/features.tsx14server
src/data/heroTech.tsx7server
src/data/footer.tsx6server
src/data/techStack.tsx5server
organisms/Trust.tsx3server
organisms/Testimonials.tsx3server
molecules/TechStackCard.tsx3server
organisms/PaymentInfo.tsx2server
organisms/Footer.tsx2server
organisms/DocsContent.tsx2server
molecules/TemplateGallery.tsx2client
organisms/UseCases.tsx, molecules/HeroBadge.tsx, molecules/BlogCard.tsx1 eachserver
organisms/Header.tsx, molecules/FaqItem.tsx, molecules/AuthCard.tsx1 eachclient

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

ApproachShips JSPer-icon costGood for
Icon library (lucide-react, react-icons)Yes, into the route bundleOne component per import, tree-shakenStateful icons; large UIs needing one consistent grid and stroke
Inline <svg> in a Server ComponentNoBytes of HTML, once, at build timeStatic decoration, brand marks, anything animated by an ancestor's group-hover
SVG sprite (<use href="#id">)NoOne request, cached across pagesDozens of repeats of the same handful of glyphs
<img src="icon.svg">NoOne request per iconIcons 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

SymptomCauseFix
next build fails on an SVG that renders fine in devA lowercase SVG element name (<clippath>, <lineargradient>) — dev treats it as an unknown host element, the build type-checks itConvert every element and attribute to JSX casing: <clipPath>, <linearGradient>, strokeWidth, fillRule
Icon ignores text-* color utilitiesThe SVG hardcodes fill="#000" instead of inheritingUse 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 chunkA barrel import (import * as Icons) or a library published without ESM/sideEffects: falseImport named icons only (import { Eye } from "lucide-react"), and check the library ships ES modules
Icon appears in the accessibility tree as unlabeled graphicDecorative SVG with no aria-hiddenAdd 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 pageThe library component was imported into a Server Component that had no other reason to be clientInline the SVG instead, or move just the interactive part behind its own "use client" boundary
Icon renders at the wrong sizeNo explicit height/width class; the SVG's intrinsic size winsSet 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.

Keep reading

Tutorial8 min read

Is React a Framework? 110 of 139 Editions Say Next.js

React ships no router, no data layer, no build step and no server. Settled with inventory: 139 framework editions across 111 products, and the 9 built twice.

Read more
Tutorial8 min read

Real Estate Landing Pages Need One Section Generic Advice Skips

A filterable listings grid, agent profiles, and a valuation funnel — not a demo request. What a real-estate landing template actually ships, using our own catalog as the reference.

Read more