How to Build a Custom SVG Icon Library & NPM Package
The Production Architecture Blueprint: Distributing custom brand icons across multiple apps requires more than just copying raw SVG files. A modern corporate icon package must deliver:
- Automated SVGO Processing: Deterministic viewBox normalization (e.g.
24x24), stripping rogue designer fills, and converting strokes tocurrentColor. - Multi-Framework Output: Generate pure React JSX components, Vue 3 SFCs, and raw optimized SVGs from a single SVG source folder.
- Zero-Overhead Bundling: Compile with
tsupto produce dual ESM (.mjs) and CJS (.cjs) builds with strict.d.tsTypeScript types in under 2 seconds. - Guaranteed Tree-Shaking: Set
"sideEffects": falseand modernpackage.jsonsubpath exports so consumers import only what they render.
1. Why Teams Need a Dedicated Icon NPM Package
As organizations grow beyond a single web app, icon management quickly deteriorates. Teams copy and paste SVG strings between GitHub repositories, resulting in inconsistent stroke weights, conflicting viewBox boundaries, and bloated production bundles.
By publishing a dedicated package (such as @acme/icons) through npm or an internal GitHub Packages registry, you establish a single source of truth for design systems:
- Designers update vector icons in Figma or curate them from IconStash's 134,701 open-source icons.
- A GitHub Actions workflow cleans, validates, and compiles icons into typed components.
- Frontend apps in Next.js, Vite, or React Native update their
package.jsondependency and immediately inherit brand-consistent iconography.
2. Repository Architecture & Folder Structure
Initialize a new package and install the core build and optimization toolchain:
# 1. Initialize project
mkdir acme-icons && cd acme-icons
npm init -y
# 2. Install TypeScript, bundler, SVGO & release tooling
npm install -D typescript tsup svgo tsx fs-extra change-case @types/node @types/fs-extra @types/react react @changesets/cli
The cleanest repository architecture separates source SVG assets, code generation scripts, and compiled distribution output:
acme-icons/
├── .github/workflows/release.yml
├── .changeset/
├── raw-svg/ # Input SVG files from designers or IconStash
│ ├── arrow-right.svg
│ ├── check.svg
│ └── user.svg
├── scripts/
│ ├── optimize.ts # SVGO cleaning script
│ └── generate.ts # TSX component generator
├── src/ # Auto-generated source files
│ ├── index.ts
│ └── icons/
│ ├── ArrowRight.tsx
│ ├── Check.tsx
│ └── User.tsx
├── package.json
├── svgo.config.js
├── tsconfig.json
└── tsup.config.ts
3. Step 1: Automated SVGO Cleaning & Normalization
Raw SVGs from design tools contain extraneous metadata (Sketch tags, Illustrator IDs, Adobe namespaces) and hardcoded colors that break UI theming. Create a hardened, modern ESM svgo.config.js:
export default {
multipass: true,
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false, // MANDATORY: Preserves responsive scaling
cleanupIds: true,
},
},
},
'removeDimensions', // Strips hardcoded width/height so CSS controls size
{
name: 'addAttributesToSVGElement',
params: {
attributes: [
{ fill: 'none' },
{ stroke: 'currentColor' },
{ 'aria-hidden': 'true' },
],
},
},
],
};
Next, write scripts/optimize.ts to clean all incoming vectors:
import fs from 'fs-extra';
import path from 'path';
import { optimize } from 'svgo';
import config from '../svgo.config.js';
async function runOptimization() {
const rawDir = path.resolve('raw-svg');
const files = await fs.readdir(rawDir);
for (const file of files) {
if (!file.endsWith('.svg')) continue;
const filePath = path.join(rawDir, file);
const content = await fs.readFile(filePath, 'utf8');
const result = optimize(content, { path: filePath, ...config });
await fs.writeFile(filePath, result.data, 'utf8');
}
console.log(`Successfully normalized ${files.length} SVG icons.`);
}
runOptimization();
4. Step 2: Automated Component Generation (React & TypeScript)
Rather than manually writing React components for each icon, use an automated template generator in scripts/generate.ts:
import fs from 'fs-extra';
import path from 'path';
import { pascalCase } from 'change-case';
async function generateComponents() {
const rawDir = path.resolve('raw-svg');
const srcDir = path.resolve('src/icons');
await fs.ensureDir(srcDir);
const files = await fs.readdir(rawDir);
const exports: string[] = [];
for (const file of files) {
if (!file.endsWith('.svg')) continue;
const name = path.basename(file, '.svg');
const componentName = `${pascalCase(name)}Icon`;
const svgContent = await fs.readFile(path.join(rawDir, file), 'utf8');
// Extract internal path elements from SVG and normalize kebab-case attributes for React JSX
const innerSvg = svgContent
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '')
.replace(/stroke-width=/g, 'strokeWidth=')
.replace(/stroke-linecap=/g, 'strokeLinecap=')
.replace(/stroke-linejoin=/g, 'strokeLinejoin=')
.replace(/fill-rule=/g, 'fillRule=')
.replace(/clip-rule=/g, 'clipRule=')
.replace(/clip-path=/g, 'clipPath=')
.trim();
const componentCode = `import React, { forwardRef } from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number | string;
}
export const ${componentName} = /*#__PURE__*/ forwardRef<SVGSVGElement, IconProps>(
({ size = 24, strokeWidth = 2, className, ...props }, ref) => {
return (
<svg
ref={ref}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
{...props}
>
${innerSvg}
</svg>
);
}
);
${componentName}.displayName = '${componentName}';
`;
await fs.writeFile(path.join(srcDir, `${componentName}.tsx`), componentCode);
exports.push(`export * from './icons/${componentName}';`);
}
// Generate barrel index
await fs.writeFile(path.resolve('src/index.ts'), exports.join('\n') + '\n');
console.log(`Generated ${exports.length} typed TSX icon components.`);
}
generateComponents();
5. Step 3: Fast Compilation with tsup (ESM + CJS + DTS)
Using standard tsc or heavy Webpack configurations produces slow builds. In 2026, tsup (powered by esbuild) is the gold standard. Create tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/icons/*.tsx'],
format: ['esm', 'cjs'],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
treeshake: true,
minify: false, // Let consumer bundler handle minification for cleaner debugging
outDir: 'dist',
external: ['react'],
});
Running npx tsup compiles your entire library into individual ES modules and CommonJS fallbacks in under 2 seconds.
6. Step 4: Configuring package.json Subpath Exports & sideEffects
The single most critical step in creating a commercial icon library is configuring package.json exports so bundlers (Vite, Next.js, Rollup, Webpack 5) can eliminate unused icons.
{
"name": "@acme/icons",
"version": "1.0.0",
"description": "Acme corporate design system vector icons",
"license": "MIT",
"sideEffects": false,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./icons/*": {
"types": "./dist/icons/*.d.ts",
"import": "./dist/icons/*.js",
"require": "./dist/icons/*.cjs"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"raw-svg"
],
"peerDependencies": {
"react": ">=18.0.0"
},
"scripts": {
"build:svg": "tsx scripts/optimize.ts",
"build:generate": "tsx scripts/generate.ts",
"build:bundle": "tsup",
"build": "npm run build:svg && npm run build:generate && npm run build:bundle",
"prepublishOnly": "npm run build"
}
}
Without "sideEffects": false, bundlers must assume that importing import { CheckIcon } from '@acme/icons' executes global state mutations in unreferenced icon files. Adding "sideEffects": false tells Webpack and Vite to strip all other 499 icons from the client bundle.
7. Architecture Comparison: Barrel vs Subpaths vs Web Fonts
Compare the bundling efficiency of different icon package distribution strategies:
| Architecture | Client Bundle (1 Icon) | Tree-Shaking Safety | TypeScript Autocomplete | Dual CJS/ESM Support | Maintenance Overhead |
|---|---|---|---|---|---|
| Modern Subpaths + tsup (This Blueprint) | ~0.4 KB | 100% Guaranteed | Instant & Strict | Native (Automatic) | Low (Fully Scripted) |
| Monolithic Barrel (Single index.js) | ~120 KB to 500 KB | Fragile (Breaks on CJS) | High memory lag | Often broken | Low |
| Icon Fonts (TTF / WOFF2 Package) | ~1.2 MB to 3 MB | 0% (Ships all glyphs) | String literals only | N/A | High (Font generation) |
| Unplugin-Icons Virtual Module | ~0.4 KB | 100% Compiler-level | Good (Virtual types) | Vite/Webpack only | Requires bundler plugin |
8. Automated CI/CD Publishing with Changesets & GitHub Actions
To eliminate manual npm version conflicts, use @changesets/cli. When developers submit a PR modifying icons, they run npx changeset to record a semver intent (patch, minor, or major).
GitHub Actions Workflow (.github/workflows/release.yml)
name: Release Icon Package
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release to NPM
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Install Dependencies
run: npm ci
- name: Build Package
run: npm run build
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
publish: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
When merged, this workflow automatically opens a Version Packages PR that updates package.json and CHANGELOG.md. Merging that PR publishes the updated icon package to npm with provenance signing.
Frequently Asked Questions
How do you guarantee 100% tree-shaking in an icon npm package?
To guarantee tree-shaking, add 'sideEffects: false' to package.json, output individual ES module (.mjs) files alongside barrel indexes, emit /*#__PURE__*/ annotations on component declarations, and configure modern package.json subpath exports.
Why use tsup over Rollup or Webpack for building icon libraries?
tsup is powered by esbuild and offers near-instant compilation (building 500+ icon components in under 1.5 seconds) while automatically generating TypeScript .d.ts declarations, dual ESM/CJS bundles, and sourcemaps with zero complex configuration.
What package.json configuration prevents barrel import bloat?
Use conditional subpath exports in package.json (such as './icons/*' mapping directly to './dist/icons/*.mjs'). This allows consumers to import directly from '@company/icons/icons/User' or rely on bundler tree-shaking with zero risk of bundling the entire library.
How do you automate version releases with Changesets and GitHub Actions?
Use @changesets/cli in your repository. Developers include markdown change fragments in pull requests. On merge to main, a GitHub Actions workflow creates an automated 'Version Packages' PR that bumps semantic versions, updates CHANGELOG.md, and publishes to npm with provenance.