Design Systems Engineering

How to Build a Custom SVG Icon Library & NPM Package

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.json dependency 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"
  }
}
Why "sideEffects": false is Mandatory

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.

Source 134K+ Icons for Your Design System

Explore 134,701 open-source vector icons across 28 curated libraries on IconStash. Fast, free, and licensed for commercial design systems under MIT and Apache 2.0.

Search All 134K+ Icons →