Design Systems & Automation

Figma-to-Code SVG Icon Pipeline: Automating Assets via REST API & GitHub Actions

The Hidden Cost of Broken Manual Handoffs

In most product organizations, shipping an icon involves a designer drawing a glyph, clicking "Export SVG" in Figma, dragging the file into Slack, and an engineer copying the raw XML into a component file. This manual process introduces four severe technical debt vectors:

  1. ViewBox Drift: One icon exports as viewBox="0 0 24 24", while another exports as viewBox="0 0 23.4 24.1" due to stray vector handles or uncentered artboards, breaking optical alignment across tables and toolbars.
  2. Stray Figma Clip-Paths: Figma frequently generates empty framing rectangles: <rect width="24" height="24" fill="white"/> or redundant <clipPath> wrappers that increase DOM complexity and prevent dynamic CSS fills.
  3. Hardcoded Fills & Strokes: Icons contain fixed hex values (e.g., fill="#1E293B") instead of semantic currentColor, breaking dark mode switching and state variants (hover, active, disabled).
  4. Divergent Source of Truth: Over time, engineers tweak code components while designers update the Figma file, creating an unbridgeable rift between design specs and production code.

The Continuous Icon Pipeline Architecture

The modern continuous icon delivery pipeline treats Figma as the single upstream database of record and code repositories as downstream build artifacts:

Stage Tool / Protocol Responsibility Output
1. Source of Truth Figma Component Library Designers create & version icons on a strict 24x24 grid Figma Document Nodes
2. Extraction Figma REST API (/v1/files & /v1/images) Traverses icon page, filters components, requests SVG renders Raw SVG strings
3. Sanitization SVGO (Multi-Pass Engine) Strips clip-paths, removes stray fills, sets currentColor Normalized Clean SVGs
4. Code Generation Node.js AST / Template Compiler Generates typed React (.tsx), Vue (.vue), or Svelte components Typed Component Library
5. Continuous Delivery GitHub Actions Workflow Detects changes, formats with Prettier, and opens automated PR Reviewable GitHub PR

Figma Canvas Standards for Automated Extraction

For an automated pipeline to succeed, the Figma file must adhere to strict component guidelines. Establishing these rules upfront eliminates 99% of downstream parsing errors:

  • Standard Artboard Frame: Every icon MUST be created inside a Figma Component sized exactly 24 × 24 pixels (or your design system's baseline grid: 16 × 16 or 20 × 20).
  • Zero Stray Outlines: Vector paths must be fully outlined and flattened into a single vector layer using Figma's Flatten command (Cmd + E / Ctrl + E) or unified boolean union.
  • Color Tokens: Use pure black (#000000) for primary strokes/fills. The build pipeline automatically maps black to currentColor.
  • Predictable Naming Hierarchy: Name components using slash notation: category/icon-name (e.g., navigation/arrow-right, actions/download, status/check-circle). The pipeline parses this string to construct component names (e.g., IconNavigationArrowRight.tsx).

The Production Node.js Synchronization Script

Below is the complete, self-contained synchronization script (scripts/sync-figma-icons.mjs). It requires only standard Node.js 20+ and the svgo package:

// scripts/sync-figma-icons.mjs
import fs from 'node:fs/promises';
import path from 'node:path';
import { optimize } from 'svgo';

const FIGMA_ACCESS_TOKEN = process.env.FIGMA_ACCESS_TOKEN;
const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY;
const OUTPUT_DIR = path.resolve('src/components/icons');
const SVG_RAW_DIR = path.resolve('assets/icons-raw');

if (!FIGMA_ACCESS_TOKEN || !FIGMA_FILE_KEY) {
  console.error('Missing required FIGMA_ACCESS_TOKEN or FIGMA_FILE_KEY environment variables.');
  process.exit(1);
}

// 1. Hardened SVGO Configuration
const svgoConfig = {
  multipass: true,
  plugins: [
    'removeDimensions',
    {
      name: 'removeAttrs',
      params: { attrs: ['data-name', 'fill-rule'] }
    },
    {
      name: 'addAttributesToSVGElement',
      params: {
        attributes: [
          { 'aria-hidden': 'true' },
          { fill: 'none' },
          { stroke: 'currentColor' }
        ]
      }
    }
  ]
};

async function fetchFigma(endpoint) {
  const res = await fetch(`https://api.figma.com/v1/${endpoint}`, {
    headers: { 'X-Figma-Token': FIGMA_ACCESS_TOKEN }
  });
  if (!res.ok) throw new Error(`Figma API error: ${res.status} ${res.statusText}`);
  return res.json();
}

async function run() {
  console.log('Fetching Figma document metadata...');
  const fileData = await fetchFigma(`files/${FIGMA_FILE_KEY}?depth=2`);

  // Locate the dedicated Icons canvas page
  const iconCanvas = fileData.document.children.find(c => c.name.toLowerCase().includes('icon'));
  if (!iconCanvas) throw new Error('Could not find a page named "Icons" in Figma file.');

  // Extract all Component nodes
  const components = [];
  function traverse(node) {
    if (node.type === 'COMPONENT') {
      components.push({ id: node.id, name: node.name });
    }
    if (node.children) node.children.forEach(traverse);
  }
  traverse(iconCanvas);
  console.log(`Found ${components.length} icon components.`);

  // 2. Batch Request Image Render URLs (chunks of 50)
  const nodeIds = components.map(c => c.id).join(',');
  const imagesResponse = await fetchFigma(`images/${FIGMA_FILE_KEY}?ids=${nodeIds}&format=svg`);
  const imageMap = imagesResponse.images;

  await fs.mkdir(OUTPUT_DIR, { recursive: true });
  await fs.mkdir(SVG_RAW_DIR, { recursive: true });

  const exportIndex = [];

  // 3. Download, Clean, and Generate React/TSX Components
  for (const comp of components) {
    const renderUrl = imageMap[comp.id];
    if (!renderUrl) continue;

    const svgRes = await fetch(renderUrl);
    const rawSvg = await svgRes.text();

    // Sanitize component name: "navigation/arrow-right" -> "IconArrowRight"
    const cleanName = comp.name
      .split(/[\/\-_ ]+/)
      .map(part => part.charAt(0).toUpperCase() + part.slice(1))
      .join('');
    const componentName = `Icon${cleanName}`;

    // Optimize with SVGO
    const optimized = optimize(rawSvg, svgoConfig);
    let svgContent = optimized.data;

    // Convert raw SVG string to React JSX
    const innerSvg = svgContent
      .replace(/<svg[^>]*>/, '')
      .replace(/<\/svg>/, '')
      .replace(/stroke-width=/g, 'strokeWidth=')
      .replace(/stroke-linecap=/g, 'strokeLinecap=')
      .replace(/stroke-linejoin=/g, 'strokeLinejoin=');

    const componentCode = `import React from 'react';

export interface IconProps extends React.SVGProps<SVGSVGElement> {
  size?: number | string;
  strokeWidth?: number | string;
}

export const ${componentName}: React.FC<IconProps> = ({
  size = 24,
  strokeWidth = 2,
  className = '',
  ...props
}) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    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.trim()}
  </svg>
);
`;

    await fs.writeFile(path.join(OUTPUT_DIR, `${componentName}.tsx`), componentCode);
    exportIndex.push(`export { ${componentName} } from './${componentName}';`);
    console.log(`Generated: ${componentName}.tsx`);
  }

  // 4. Generate Barrel Index File
  await fs.writeFile(path.join(OUTPUT_DIR, 'index.ts'), exportIndex.join('\n') + '\n');
  console.log('Icon pipeline completed successfully.');
}

run().catch(err => {
  console.error(err);
  process.exit(1);
});

Hardened SVGO Rules for Figma Vector Artifacts

Standard SVGO configurations are designed for general web graphics, not automated design systems. Figma exports introduce specific quirks that require customized SVGO optimization rules:

// svgo.config.js
module.exports = {
  multipass: true,
  plugins: [
    'removeDoctype',
    'removeXMLProcInst',
    'removeComments',
    'removeMetadata',
    'removeEditorsNSData',
    'cleanupAttrs',
    'mergeStyles',
    'inlineStyles',
    'minifyStyles',
    'removeUselessDefs',
    'cleanupNumericValues',
    'convertColors',
    'removeUnknownsAndDefaults',
    'removeNonInheritableGroupAttrs',
    'removeUselessStrokeAndFill',
    'cleanupEnableBackground',
    'removeHiddenElems',
    'removeEmptyText',
    'convertShapeToPath',
    'convertEllipseToCircle',
    'moveElemsAttrsToGroup',
    'moveGroupAttrsToElems',
    'collapseGroups',
    'convertPathData',
    'convertTransform',
    'removeEmptyAttrs',
    'removeEmptyContainers',
    'mergePaths',
    'removeUnusedNS',
    'sortAttrs',
    'sortDefsChildren',
    'removeTitle',
    'removeDesc',
    {
      name: 'removeAttrs',
      params: {
        // Strip hardcoded fills and clip paths so CSS controls colors
        attrs: ['(fill|clip-path|stroke-opacity)']
      }
    }
  ]
};

Critical SVGO Plugins for Figma

  • collapseGroups: Flattens nested Figma <g> tags caused by autolayout or grouping, reducing DOM node counts by up to 60%.
  • mergePaths: Combines multiple vector paths that share the same stroke/fill into a single consolidated <path d="..." />, accelerating browser rendering.
  • cleanupNumericValues: Truncates excessive floating-point precision (e.g., d="M12.000004 3.999998..." becomes d="M12 4..."), shrinking file sizes by 35% without visual distortion.

Automated GitHub Actions CI/CD Workflow

Once the sync script is created, wire it into a GitHub Actions workflow (.github/workflows/sync-icons.yml). This workflow automatically checks for Figma updates, runs the build pipeline, and opens a structured Pull Request:

name: Sync Figma Icons

on:
  schedule:
    # Run every Monday at 08:00 UTC
    - cron: '0 8 * * 1'
  repository_dispatch:
    types: [figma-update]
  workflow_dispatch:

jobs:
  sync-icons:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Execute Figma Sync Script
        env:
          FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
          FIGMA_FILE_KEY: ${{ secrets.FIGMA_FILE_KEY }}
        run: node scripts/sync-figma-icons.mjs

      - name: Format Generated Components
        run: npx prettier --write "src/components/icons/**/*.{tsx,ts}"

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "chore(icons): sync vector assets from Figma"
          branch: chore/sync-figma-icons
          title: "🎨 Figma Icons Synchronized"
          body: |
            ### Automated Icon Library Update
            This PR was automatically generated by the Figma Icon Pipeline.
            
            - **Source File:** [Figma Design System](https://figma.com/file/${{ secrets.FIGMA_FILE_KEY }})
            - **Optimization:** SVGO multi-pass compression applied
            - **Validation:** TypeScript type-checked and formatted
          labels: |
            design-tokens
            automated-pr

Comparison: Manual vs. Plugin vs. REST API vs. IconStash

Choosing the right icon synchronization strategy depends on your team size and design velocity:

Strategy Setup Effort Human Error Rate Code Consistency Ideal Use Case
Manual Export / Slack 0 hours High (95% mismatch over time) Poor (variable viewBox & fills) Early-stage prototypes
Figma Community Plugins 1 hour Medium (requires designer manual trigger) Moderate Small teams (1-3 engineers)
Figma REST API + GitHub Actions 4-8 hours Near Zero (Fully automated) Strict (Automated SVGO & TSX) Enterprise SaaS & Design Systems
IconStash Unified Engine 0 hours Zero (Standardized Open Source) 100% (Pre-optimized JSX/Vue/SVG) Instant production UI development

Enterprise Edge Cases & Solutions

1. Multi-Color & Duotone Layer Handling

If your design system uses duotone icons (e.g., a primary stroke with a 20% opacity tinted fill), a naive removeAttrs: ['fill'] plugin will destroy the duotone effect. Instead, configure your pipeline to inspect Figma layer names:

  • If a layer is named accent or secondary, map its color to a secondary prop: fill={secondaryColor} and fillOpacity={secondaryOpacity}.
  • If a layer is named primary or outline, map it to stroke={color}.

2. Mask ID Deduplication

When Figma exports complex vector masks, it uses static identifiers like <mask id="mask0">. If two different icons on a page both use id="mask0", browsers will apply the first mask to both icons, corrupting the visual rendering. Solve this in your pipeline script by appending the icon's component name: id={`mask-${componentName.toLowerCase()}`}.

Need Production-Ready Icons Right Now?

Skip the pipeline setup. Access 134,701 pre-optimized, standardized vector icons across 28 curated libraries on IconStash.

Explore 134K+ Icons on IconStash →

Frequently Asked Questions

How does the Figma REST API export vector SVG icons?

The pipeline queries the Figma GET /v1/files/:key endpoint to traverse component nodes on the designated icon canvas page. It then requests render URLs from GET /v1/images/:key?format=svg for each component ID and downloads the pure vector markup.

Why is SVGO optimization necessary for Figma SVG exports?

Figma exports often contain redundant bounding box rectangles, unflattened clip-paths, hardcoded fills, and high-precision floats that bloat file sizes. SVGO removes these artifacts, injects currentColor, and standardizes the viewBox to 0 0 24 24.

How can GitHub Actions automate Figma icon updates?

A GitHub Actions workflow runs on a weekly schedule or a Figma webhook trigger. It executes a Node.js sync script that downloads new SVGs, runs SVGO, generates typed components, and opens an automated Pull Request with visual diffs for engineering review.

How do you handle multi-color or duotone icons in an automated pipeline?

Designers apply semantic color names or opacity values in Figma. The pipeline script detects secondary layers via layer name conventions (such as 'secondary' or 'accent') and generates dual-prop components with primary and secondary color tokens instead of forcing a single currentColor.