How to svg icon systems in next.js: from export to production component
- Step 1Export and normalise icons from Figma — Export icon frames as SVG at a single canonical size (e.g. 24×24 with a real
viewBox). Before converting, strip exporter cruft: run svg-metadata-scrubber and svg-pro-minifier so the component body is the geometry, not Figma's editor metadata. Aim forcurrentColorfills so theming works. - Step 2Standardise colour to tokens before JSX — If Figma exported hard-coded hex (
#1F2937), the icon won't follow your theme. Run svg-to-tailwind (current mode) or svg-hex-swapper to convert fixed paints tocurrentColor, so a singletext-…class on the parent themes the icon. Do this before converting to JSX. - Step 3Convert to typed TSX components — Convert each normalised SVG with svg-to-jsx, TypeScript on, and a PascalCase name matching the design name (
icon-search→SearchIcon). The output is aReact.SVGProps<SVGSVGElement>-typed component with{...props}andclassNamewired on the root. - Step 4Namespace ids and fix inline styles — Before committing, prefix internal ids per component (
clip0→search-clip0) and rewrite the matchingurl(#…)/href="#…"references, so rendering two icons on a page doesn't collide. Convert any inlinestyle="…"strings to objects — the converter leaves them as strings, which React rejects. - Step 5Wrap for sizing and a11y conventions — The converter generates no
sizeprop. Either standardise on utility classes (<SearchIcon className="h-5 w-5" />) or add a thin<Icon>wrapper that maps asizetoken to width/height. Establish the a11y default: decorative icons getaria-hidden, meaningful ones getrole="img"+aria-label— both pass through{...props}. - Step 6Expose via a typed barrel and validate — Generate
index.tsre-exporting every icon, add it to your packageexports, and gate CI withtsc --noEmitplus a visual snapshot. Keep each icon in its own module so consumers tree-shake to only what they import.
What the converter gives you vs. what your system must add
The converter output is a solid base for a Next.js icon — but a production system layers a few conventions on top of what svg-to-jsx actually emits.
| System requirement | Converter provides | You add |
|---|---|---|
| Typed props | Yes — React.SVGProps<SVGSVGElement> | Nothing |
| Prop forwarding / className | Yes — {...props} className={className} | Nothing |
| Server-Component-safe | Yes — pure renderer, no hooks | Don't add 'use client' |
| size prop | No | Utility classes or a thin wrapper |
| currentColor theming | Preserves it; doesn't add it | Normalise colours first (svg-to-tailwind) |
| Unique ids per instance | No — ids kept verbatim | Namespace ids in a post-pass |
| Inline style handling | No — left as strings | Convert to style objects |
| forwardRef | No | Wrap if you need refs (or use @svgr) |
Theming matrix — how a converted icon follows tokens
Theming works only when the SVG uses currentColor and the component is inline (which it is). Hard-coded hex won't follow tokens.
| Icon source paint | Behaviour after conversion | To theme it |
|---|---|---|
| fill="currentColor" | Inherits parent CSS color | <Icon className="text-token-icon" /> |
| stroke="currentColor" | Inherits parent CSS color | Same — set color on a parent |
| fill="#1F2937" (hex) | Stays fixed grey, ignores theme | Pre-convert to currentColor (svg-to-tailwind) |
| fill="url(#grad)" | Gradient kept; not token-driven | Drive stops with CSS vars (svg-css-variable-injector) |
| style="fill:#000" (string) | React throws | Refactor to attribute or style object |
Folder and module contract for a Next.js icon package
One module per icon keeps tree-shaking honest; the barrel is for ergonomics, not bundling.
| Path | Contents | Why |
|---|---|---|
| src/assets/icons/*.svg | Normalised source SVGs (currentColor, viewBox) | Single source of truth for regeneration |
| src/components/icons/<Name>.tsx | One converted component per icon | Tree-shakeable; import only what you use |
| src/components/icons/index.ts | Re-exports each component | import { SearchIcon } from '@/components/icons' |
| src/components/Icon.tsx | Optional size/a11y wrapper | Standardises sizing the converter doesn't emit |
Cookbook
The pieces of a real Next.js icon system, grounded in the component the converter actually emits.
A converted icon dropped into the App Router as a Server Component
No 'use client' — the converter output is a pure renderer, so Next.js ships zero client JS for it. Theming is a token class on the parent.
// src/components/icons/SearchIcon.tsx (from svg-to-jsx, TSX on)
import React from 'react';
export const SearchIcon = ({ className, ...props }: React.SVGProps<SVGSVGElement>): React.ReactElement => (
<svg {...props} className={className} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2">…</svg>
);
export default SearchIcon;
// app/(dashboard)/page.tsx — Server Component, no client JS
import { SearchIcon } from '@/components/icons';
<SearchIcon className="h-5 w-5 text-slate-400" aria-hidden />Token-driven theming with currentColor
Define an icon token as a CSS variable mapped to a Tailwind utility, and every currentColor icon follows light/dark automatically. This only works because the SVG uses currentColor and the icon is inline.
:root { --icon: theme(colors.slate.500); }
.dark { --icon: theme(colors.slate.300); }
.text-token-icon { color: var(--icon); }
// One class themes every icon, light and dark:
<nav className="text-token-icon">
<SearchIcon className="h-5 w-5" />
<BellIcon className="h-5 w-5" />
</nav>A thin Icon wrapper to supply the missing size prop
The converter emits no size prop. If your design system speaks in size tokens, wrap once and map tokens to width/height — keeping every per-icon component untouched.
const SIZES = { sm: 16, md: 20, lg: 24 } as const;
type Props = React.SVGProps<SVGSVGElement> & { icon: React.FC<React.SVGProps<SVGSVGElement>>; size?: keyof typeof SIZES };
export function Icon({ icon: Glyph, size = 'md', ...rest }: Props) {
const px = SIZES[size];
return <Glyph width={px} height={px} aria-hidden {...rest} />;
}
// Usage:
<Icon icon={SearchIcon} size="lg" className="text-token-icon" />Namespacing ids so two of the same icon don't collide
The converter keeps ids verbatim. A masked/gradient icon rendered twice references the first instance's defs. Namespace ids per component in your regeneration script.
// Before (two <LogoIcon/> on one page → broken second mask): <clipPath id="clip0">… fill="url(#paint0_linear)" // Regeneration post-pass output, ids prefixed per component: <clipPath id="LogoIcon-clip0">… fill="url(#LogoIcon-paint0_linear)" // references rewritten to match → both instances render correctly.
Regeneration when Figma changes (keep design ↔ code in sync)
A repeatable pipeline so a design update is a script run, not manual surgery. Export → normalise → convert → namespace ids → barrel → type-check.
# 1. Export changed icons from Figma to src/assets/icons/ # 2. Normalise: scrub metadata + minify + currentColor # 3. Convert each to TSX via the local runner (engine-mode, stays local) # 4. Post-pass: namespace ids, fix any inline styles # 5. Regenerate src/components/icons/index.ts barrel # 6. npx tsc --noEmit && run visual snapshots # Wire steps 2-6 into one npm script: "icons:build"
Edge cases and what actually happens
Adding 'use client' to every icon
WastefulConverted icons are pure renderers with no hooks or handlers, so they're valid Server Components. Slapping 'use client' on each one needlessly ships client JS for static glyphs and can pull a server tree into the client bundle. Keep icons server-rendered; only the interactive wrapper (a button with onClick) needs the client boundary.
Hard-coded hex icons that ignore the theme
Pre-processFigma often exports fill="#1F2937". After conversion that icon stays fixed grey and never follows your dark-mode token. The converter preserves colours; it doesn't tokenise them. Normalise to currentColor with svg-to-tailwind (current mode) or svg-hex-swapper before converting, then theme with one text-… class.
Two instances of the same icon on one page
CollisionMasks, gradients, and clip-paths use ids the converter keeps verbatim. Render the icon twice and the second instance references the first's defs, so it renders wrong (missing clip, flat gradient). Namespace ids per component in your build pipeline, or use svg-sprite-builder for heavily-repeated icons.
Inline style strings from the design tool
Needs manual fixSome exporters emit style="…" on elements. The converter passes the string through and React throws because style must be an object. Either configure the export to use presentation attributes, strip styles in the normalise step, or add a style-string-to-object post-pass before committing.
Expecting a generated size prop
Not providedThe converter emits no size prop — sizing comes through {...props}/className. Standardise on utility classes (h-5 w-5) or a thin <Icon size=…> wrapper that maps tokens to width/height. Don't wait for the converter to add it; design the convention.
Barrel file defeating tree-shaking
Bundle bloatA single index.ts re-exporting hundreds of icons is convenient but can prevent some bundlers from dropping unused icons, especially with side-effectful modules. Keep each icon in its own module, mark the package side-effect-free, and import from specific modules when bundle size is critical.
Icon needs a forwardRef (e.g. for a tooltip/focus library)
Not supportedThe converter does not wrap components in forwardRef, so libraries that need a ref to the SVG element won't get one. Wrap the converted component in React.forwardRef yourself, or generate that subset with @svgr (ref: true). Most icons don't need refs, so apply this only where required.
Component name collides with an existing export
Build errorTwo source files PascalCasing to the same name (menu.svg and Menu.svg) produce duplicate export const Menu, breaking the barrel. Enforce unique, validated names in the regeneration script and fail the build on collision rather than shipping a shadowed export.
Filter-heavy 'duotone' icons warn in React
Dev warningIf your set includes filter primitives (flood-color, lighting-color), those attributes aren't on the rename list and React warns. Add the renames to your post-pass, or keep filter-heavy art as external <img>/sprite assets rather than inline components.
viewBox missing on the source SVG
Scaling issueAn icon without a viewBox won't scale predictably when you set width/height or CSS sizes on the component. The converter doesn't synthesise a viewBox. Fix it at the source (or with svg-viewbox-fixer) before converting so the icon scales cleanly in the system.
Frequently asked questions
Should icon components be Server or Client Components in Next.js?
Server Components. The converter output is a pure rendering function with no hooks or event handlers, so it works as a Server Component by default and ships zero client JS for the icon. Only the interactive element that contains the icon — a button with onClick, a hover-driven control — needs 'use client'. Putting the directive on every icon needlessly grows the client bundle.
How do I size icons consistently without a size prop?
The converter doesn't emit a size prop, so standardise sizing one of two ways: utility classes on every usage (<SearchIcon className="h-5 w-5" />), or a thin <Icon size="md"> wrapper that maps size tokens to width/height and renders the per-icon component. Both rely on the {...props}/className wiring the converter already provides. Pick one convention and lint for it.
How does theming work across light and dark mode?
Make icons use currentColor (normalise hard-coded hex first with svg-to-tailwind current mode), then drive color from a design token: define --icon per theme, map a utility class to it, and put that class on a parent. Every currentColor icon underneath follows the token automatically. This works because the icons are inline — <img>-based icons can't inherit color.
Why do two of the same icon render wrong on one page?
Id collision. The converter keeps internal ids (clip0, paint0_linear) verbatim, so two instances both reference the first one's clipPath/gradient. In a design system, namespace ids per component in your regeneration script — prefix the id and rewrite the matching url(#…)/href="#…" references. For icons rendered dozens of times, a <symbol> sprite via svg-sprite-builder avoids the issue entirely.
How do I keep the icon library in sync with Figma?
Treat regeneration as a script, not manual work: export changed frames to src/assets/icons, normalise (scrub metadata + minify + currentColor), convert each to TSX (via the local runner so assets stay on your machine), namespace ids, rebuild the barrel, then tsc --noEmit and run visual snapshots. Wire it into one icons:build npm script so a design change is one command.
Do the icons tree-shake?
Yes, if you keep one module per icon and import from the specific module (or a tree-shakeable barrel) and mark the package side-effect-free. A monolithic barrel that re-exports everything can defeat tree-shaking in some bundlers. Each converted icon is a normal ES module export, so standard dead-code elimination applies when consumers only import what they render.
What if Figma exported inline styles?
The converter leaves style="…" as a string, and React throws on string styles. Fix it upstream: configure the Figma export (or your normalise step) to emit presentation attributes instead of inline styles, or add a post-pass that converts style strings to objects before committing. Catch it with tsc/a render test so a styled icon never ships broken.
Can I add a forwardRef to the icons?
Not from the converter — it doesn't wrap in forwardRef. If a tooltip/focus/animation library needs a ref to the SVG element, wrap the converted component in React.forwardRef yourself, or generate that subset with @svgr (ref: true). Most icons in a system don't need refs, so apply this only where a library requires it.
Should I minify before converting?
Yes. Inline icons become JavaScript in your bundle, so smaller source means a smaller component. Run svg-pro-minifier and svg-precision-tuner (and svg-metadata-scrubber to drop editor cruft) before svg-to-jsx. The converter only strips comments and xmlns:xlink; it does not minify geometry or remove unused defs.
How do I handle accessible vs decorative icons?
Establish a convention and rely on prop forwarding. Decorative icons get aria-hidden="true"; meaningful standalone icons get role="img" + aria-label. Both pass through the {...props} the converter wires onto the root <svg>: <SearchIcon aria-hidden /> or <LogoIcon role="img" aria-label="Acme" />. A wrapper can default aria-hidden and let callers opt into a label.
Is this safe for unreleased brand assets?
Yes. The web tool converts in your browser (the result panel shows '0 bytes uploaded'), and the automation path runs through the local @jadapps/runner in engine mode, so SVGs never leave your network. That's the right model for a design system whose logos and product icons aren't public yet.
Can the same components work in a non-Next.js React app?
Yes. The output is framework-agnostic React with a single import React and standard SVGProps typing — it works in any React 18+ app (Vite, CRA successors, Remix). The Server-Component benefit is Next-specific, but the components themselves are portable. For Vue or Svelte design systems, use svg-to-vue-svelte instead.
Privacy first
Every JAD SVG tool runs entirely in your browser using the DOM API and Canvas. Your SVG files never leave your device — verified by zero outbound network requests during processing.