An opinionated command-line tool to combine multiple SVG files into a single file that utilizes the <symbol> element.
- SVG optimization using SVGO
- Removal of colored
fillandstrokeattributes so they inherit from parent CSS — while preservingfill="none"so unpainted regions stay transparent. - (Optional) Type-safe React component export.
- Bundler-agnostic: a standalone CLI build step, not a plugin — no loader config to maintain.
For many years SVGR has been the de facto solution for rendering SVGs in React apps. (Perhaps because it was bundled with Create React App.) However, after working on production-facing, high-traffic websites for many years, I've realized that importing SVGs one-by-one as React components has real performance issues, mainly:
- The SVG components can represent a large percentage of your bundled script size.
- When rendered, the SVG components can add a huge amount of DOM nodes to your page. Excessive DOM size can adversely affect your Lighthouse score.
This library is most useful when you have a large number of monochrome SVGs to display on a website - perhaps in multiple places on a single page - and the fill color needs to be modified. That is to say, this library is for icons. Complex SVGs are outside the concerns of this library. For those types of SVGs, I recommend creating a separate process to optimize with SVGO and to import them on an ad-hoc basis.
While stroke manipulation is possible, it is a best practice to export SVGs with "outlined strokes" so all files can be manipulated predictably.
fill="none"is preserved. Only coloredfill/strokeattributes are stripped (so the icon inheritscolor); an explicitfill="none"is left intact. Icons that rely on unpainted regions — rings, holes, outline-plus-fill pairs, even-odd cutouts — render correctly. (stroke="none"may still be dropped as redundant, sincenoneis the SVG default forstroke. Colors set via inlinestyle— e.g.style="fill:#000"— are not touched, so export flatfill/strokeattributes rather than inline styles.)
Symbol Store is a standalone command-line tool: you run it and it writes a sprite file (and, optionally, a typed React helper) to disk. It isn't a bundler loader or plugin, so there's no loader configuration to maintain and nothing to wire into your bundler's module resolution.
The practical consequence is that it's bundler-agnostic. The generated .svg and .tsx are ordinary files — the sprite is referenced by URL, and the helper is a plain React component — so they behave identically whether your app is built with Turbopack, webpack, Vite, Rollup, or no bundler at all. Run it however suits your project — a prebuild script, another package script, or by hand — and consume the output like any other file.
yarn add @timhettler/symbol-storeNode ≥ 18.20 or ≥ 20.10 is required to run the CLI (it uses JSON import attributes, which 19.x and 20.0–20.9 don't support).
symbol-store -i ./icons -o ./public -t ./src/componentsIcons in nested sub-folders are included too (the input directory is walked recursively). Symbol ids come from filenames, so a given name must be unique across all folders.
symbol-store is a one-shot build step — it doesn't watch. In development, re-run it whenever icons change with any file watcher, e.g. chokidar-cli:
chokidar "icons/**/*.svg" -c "symbol-store -i ./icons -o ./public -t ./src/components"Or run it next to your dev server with concurrently:
"scripts": {
"dev": "concurrently \"next dev\" \"chokidar 'icons/**/*.svg' -c 'symbol-store -i ./icons -o ./public -t ./src/components'\""
}Run symbol-store -h for details in your terminal.
| Option | Required | Description | Default |
|---|---|---|---|
-i |
Y | Path containing SVG files | N/A |
-o |
N | Path to output the combined SVG | Input path |
-t |
N | Create a TypeScript file? | Output path |
-c |
N | Name for the generated component (and its file) | Icon |
--hash |
N | Append a content hash to the sprite filename | false |
-r |
N | Deprecated alias for --hash |
false |
-p |
N | URL to proxy SVG requests | N/A |
--inline |
N | Emit an inline <SymbolStoreSprite> (no proxy, cross-origin) |
false |
The --hash suffix is a short, deterministic hash of the sprite's contents: the filename stays identical across builds and machines when the icons don't change, and changes only when they do. That makes a hashed sprite safe to serve with a long-lived immutable cache while still busting automatically after an icon update. (-r is kept as a deprecated alias — it used to append a random number.)
The generated component is named Icon by default. Pass -c/--component-name (a PascalCase name) to change it — e.g. -c Glyph emits Glyph.tsx exporting Glyph.
The generated Icon component ships accessibility defaults so icons behave correctly without extra boilerplate on every usage.
Decorative (default). With no title, the icon is hidden from assistive technology (aria-hidden="true", focusable="false") — most icons sit beside a text label and shouldn't be announced twice:
<Icon node="trash" /> // decorative: not announcedMeaningful. Pass a title to expose the icon as an image with an accessible name — it renders role="img", an aria-label, and a <title> element (native tooltip):
<Icon node="trash" title="Delete" /> // announced as "Delete"title is the only prop added on top of the standard SVGProps<SVGSVGElement>. Because your props are spread after these defaults, you can still override role or any aria-* attribute when a specific case calls for it.
Icon-only controls need a name. An icon that is the only content of an interactive control —
<button><Icon node="trash" /></button>— produces an unnamed button, because the decorative default hides the icon. Give the icon atitle, or label the control itself (e.g.aria-labelon the<button>). An emptytitle=""is treated as no title, so it stays decorative.
The SVG <use> element does not work with cross-origin requests. If your symbol is hosted on a different domain than your application, you'll need to proxy the request. Here's an example using Next.js route handlers:
symbol-store -i ./icons -o ./public -t ./src/components -p /api/symbol-storeThis will generate a React component that uses the proxy URL:
export const Icon = ({ node, ...props }: IconProps) => (
<svg {...props}>
<use href={`/api/symbol-store#${node}`} />
</svg>
);Simplified for clarity —
--proxyonly changes the<use>reference. The real generated component also includes accessibility defaults.
You'll need to create a route handler to proxy the requests:
// app/api/symbol-store/route.ts
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
export async function GET(request: Request) {
const isDev = process.env.NEXT_PHASE === PHASE_DEVELOPMENT_SERVER;
// In development, read the sprite from ./public on disk. In production, fetch
// it from your CDN — server-side fetches aren't subject to the `<use>`
// cross-origin restriction that breaks the reference in the browser.
const svg = isDev
? await readFile(
path.join(process.cwd(), "public", "symbolstore.svg"),
"utf-8"
)
: await fetch("https://cdn.mydomain.com/symbolstore.svg").then((res) =>
res.text()
);
// This endpoint has a *stable* URL (`/api/symbol-store` — no hash is baked in),
// so its contents are mutable and must NOT be served `immutable`: that would
// pin returning visitors to a stale sprite for up to a year after a redeploy.
// Attach a content ETag and revalidate instead — an unchanged sprite returns a
// cheap 304, and an icon update is picked up on the next request.
const etag = `"${createHash("sha256").update(svg).digest("hex").slice(0, 16)}"`;
const cacheControl = "public, max-age=0, must-revalidate";
if (request.headers.get("if-none-match") === etag) {
return new NextResponse(null, {
status: 304,
headers: { ETag: etag, "Cache-Control": cacheControl },
});
}
return new NextResponse(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": cacheControl,
ETag: etag,
},
});
}Caching: because the proxy URL is stable, don't mark it
immutable. If you'd rather cache at the edge,s-maxage+stale-while-revalidate(e.g.public, s-maxage=86400, stale-while-revalidate=604800) is a good alternative that bounds staleness while still revalidating. The static (non-proxy) sprite can be hashed with--hashand servedimmutable; see Caching.
Using
--hash? The on-disk sprite is then namedsymbolstore-<hash>.svg, so the proxy handler must resolve that filename rather than hardcodingsymbolstore.svg— e.g.readdir("public")and match^symbolstore(-[0-9a-f]+)?\.svg$. The demo route (test/src/app/api/symbol-store/route.ts) does exactly this, so it works with or without--hash.
Inlining bakes the sprite into the document: the <symbol> definitions are injected once so <use href="#icon"> resolves against the same document. Because nothing is loaded cross-origin, this works from any origin with no proxy, and because the definitions ship in the server-rendered HTML there's no extra request and no flash of missing icons (see First paint).
It's frequently the simplest correct choice for small-to-moderate icon sets: it sidesteps both the cross-origin <use> restriction and the cold-cache flash, with no proxy route to run or preload to configure.
- Use
--inlinewhen the sprite is small (rule of thumb: it gzips to ≲ 5–8 KB) and/or the app is navigation-light — a dashboard or SPA where a full-document load is rare. The one-copy-per-document cost is negligible and you get cross-origin correctness and instant paint for free. - Prefer the default static sprite (optionally
--hash+ animmutablecache; see Caching) when the icon set is large or the site is high-traffic with many full page loads. A separately-cached file is fetched once and reused across every page and repeat visit — more bandwidth-efficient at scale, and the Motivation case this library was built for. - Add
--proxywhen you want that separately-cached file but it lives on a different origin (a CDN).
The cost of inlining is payload: one copy of the sprite's definitions rides along in every HTML document. It's a fixed cost — not multiplied by how many icons you render — but it's re-sent on each full-page navigation instead of being cached as its own file, which is why it isn't the default.
Generate inline output with the --inline flag:
symbol-store -i ./icons -o ./public -t ./src/components --inlineAlongside Icon (whose <use href="#icon"> now resolves in-document), this emits a SymbolStoreSprite component with the sprite baked in. Render it once, high in the tree — e.g. your root layout:
// app/layout.tsx
import { SymbolStoreSprite } from "@/components/SymbolStoreSprite";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SymbolStoreSprite />
{children}
</body>
</html>
);
}SymbolStoreSprite is a server component that injects static markup, so there's no client JS and no flash: the sprite ships in the initial HTML and persists across client navigations. <Icon node="…" /> then resolves against it from any origin.
Content-Security-Policy.
--inlineinjects the sprite into your document, and the injected markup hides the sprite container with inlinestyleattributes — deliberately, because a plaindisplay:nonecan stop some browsers from resolving<use>. Under a strict CSP, inline mode therefore needsstyle-src 'unsafe-inline'(or a per-request nonce). If that isn't acceptable, prefer the default static-file or proxy mode: there the sprite lives in a separate file referenced by<use>, so none of its markup — inline styles included — enters your document.The sprite is your own build-time input, so the
dangerouslySetInnerHTMLinSymbolStoreSpriteis safe. If your icon pipeline ever ingests untrusted SVG, sanitize it before generating.
Because icons are referenced from an external file via <use href="…">, the browser must fetch the sprite before it can paint any icon — the glyphs aren't part of the server-rendered HTML. On a cold cache this means icons appear a moment after the rest of the page (a brief flash of no-icon), and the proxy can add a server round-trip (e.g. fetching from your CDN) in front of that request.
Preloading the sprite largely mitigates this. For icons that must be visible immediately — above the fold, for example — inlining the sprite with --inline avoids the extra request entirely.
Since the SVG symbol file is critical for rendering icons, it's recommended to preload it to avoid render-blocking requests. This is especially important if you're using a proxy endpoint, as the request will need to complete before any icons can be displayed.
Add the preload tag to your root layout:
<head>
<link rel="preload" href="/symbolstore.svg" as="image" type="image/svg+xml" />
</head>If you're using a proxy endpoint, preload that instead:
<head>
<link
rel="preload"
href="/api/symbol-store"
as="fetch"
crossorigin="anonymous"
/>
</head>Note: Using
as="fetch"instead ofas="image"when preloading the proxy endpoint ensures the browser makes a single request that can be reused by the<use>elements.
Note: In development (
next dev) you may see a"resource … was preloaded using link preload but not used within a few seconds"warning for the sprite. This is a dev-mode / React StrictMode artifact — in a production build the preload is consumed by<use>(a single request, no double-fetch).
Next.js doesn't add long-lived cache headers to files in public/ — only assets under /_next/static/ are served as immutable. By default the sprite is revalidated on navigation rather than cached for the long term.
You can cache it aggressively with a headers() rule in next.config.ts, but only mark it immutable if its URL changes whenever its contents do — otherwise returning visitors keep the stale sprite until the cache expires.
The safe way is to give the sprite a content-hashed filename (the --hash flag) and match that filename:
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: "/:sprite(symbolstore-[0-9a-f]+\\.svg)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
};
export default nextConfig;If you serve the stable symbolstore.svg filename instead (no --hash), don't mark it immutable; use a revalidating policy such as public, max-age=0, must-revalidate so icon updates are picked up on the next request.
The demo app's next.config.ts shows both rules side by side: an immutable rule matching the hashed pattern, and a revalidating rule for the stable filename it actually ships.