Writing an esbuild Plugin for Inline SVG Imports

You want import logo from './logo.svg' to resolve to the raw SVG markup, a data: URI, or a ready-to-render JSX component, chosen per import — and you want it to survive watch-mode edits without leaking memory or going stale. This guide builds that plugin end to end. For the resolution model it relies on, see Custom Loaders and Asset Handling in esbuild before wiring the hooks below.

esbuild’s default .svg behavior depends on the loader map: file copies it and gives you a URL, text gives you the markup, dataurl gives you a data: URI. None of those let a single project mix all three, and none emit a JSX component. A plugin built on onResolve and onLoad solves all four cases by reading an import-query suffix (?inline, ?dataurl, ?component) and producing the right module.

SVG import query routing An SVG import is tagged by query suffix in onResolve, then onLoad reads the file, consults a cache, and emits a string, data URI, or JSX component. import './x.svg' ?inline / ?dataurl / ?component onResolve strip query, tag namespace onLoad cache lookup, read + transform string export ?inline data: URI ?dataurl JSX component ?component
Figure: the query suffix selects which module shape onLoad emits; a cache keyed on path plus query short-circuits repeat reads.

Prerequisites and Reproducible Setup

Pin esbuild and create a throwaway project. The JSX path assumes a React-flavored JSX runtime, so esbuild’s jsx loader handles the output.

# esbuild 0.25.x, Node 20+
mkdir svg-plugin-demo && cd svg-plugin-demo
npm init -y
npm install --save-dev esbuild@0.25.5
npm install react@18 react-dom@18
mkdir -p src/assets
printf '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 4h16v16H4z" fill="#386641"/></svg>' > src/assets/logo.svg

Three entry imports exercise every branch:

// src/index.ts — esbuild 0.25.x
import markup from './assets/logo.svg?inline';      // -> string
import uri from './assets/logo.svg?dataurl';        // -> "data:image/svg+xml,..."
import Logo from './assets/logo.svg?component';      // -> React component

console.log(markup.startsWith('<' + 'svg'));         // true
console.log(uri.startsWith('data:image/svg+xml'));
console.log(typeof Logo);                            // "function"

Diagnosis Workflow

Before writing transform logic, confirm where the default behavior breaks:

  1. Run a build with no plugin and a text loader: esbuild src/index.ts --bundle --loader:.svg=text --outdir=dist. The query suffixes (?inline) make esbuild treat logo.svg?inline as a different, unresolvable path — you get Could not resolve "./assets/logo.svg?inline". That error is the signal that you must intercept resolution yourself.
  2. Add --log-level=debug to watch the resolver try and fail on the query-suffixed path. The plugin’s job in onResolve is to strip the query, resolve the real file, and stash the query for onLoad.
  3. Decide the namespace. Use one namespace (svg) and carry the requested kind in pluginData, so a single onLoad callback handles all three outputs without three near-identical registrations.

The Complete Plugin

This is the full plugin — copy it verbatim. It strips the query in onResolve, resolves relative to the importer, caches by absolute path plus kind, declares watchFiles for invalidation, and emits the correct module per kind. The cache is keyed so an edit to the file busts every variant of it.

// svg-import-plugin.ts — esbuild 0.25.x, Node 20+
import type { Plugin, OnLoadResult } from 'esbuild';
import { readFile, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';

type SvgKind = 'inline' | 'dataurl' | 'component';

// Cache entry keyed by `${absPath}\0${kind}`. We store the file's last mtime
// so a stale entry is detected and rebuilt even outside watch mode.
interface CacheEntry {
  mtimeMs: number;
  result: OnLoadResult;
}

function kindFromQuery(query: string): SvgKind | null {
  if (query === '?inline') return 'inline';
  if (query === '?dataurl') return 'dataurl';
  if (query === '?component') return 'component';
  return null;
}

// Minimal, dependency-free SVG-to-JSX: rename a few attributes React needs.
function svgToComponent(svg: string): string {
  const jsxBody = svg
    .replace(/<\?xml[^>]*\?>/g, '')
    .replace(/class=/g, 'className=')
    .replace(/([a-z]+)-([a-z]+)=/g, (_m, a: string, b: string) =>
      `${a}${b[0].toUpperCase()}${b.slice(1)}=`, // stroke-width -> strokeWidth
    )
    // forward incoming props (width, className, ...) onto the root svg element.
    .replace(new RegExp('<' + 'svg '), '<' + 'svg {...props} ');
  return `import * as React from 'react';
export default function SvgComponent(props) {
  return (${jsxBody});
}`;
}

function svgToDataUri(svg: string): string {
  // Percent-encode just the characters that break a data: URL. Avoids the
  // 30%+ size penalty of base64 for text payloads.
  const encoded = svg
    .replace(/"/g, "'")
    .replace(/>\s+</g, '><')
    .replace(/[#%{}<>]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
    .replace(/\s+/g, ' ')
    .trim();
  return `data:image/svg+xml,${encoded}`;
}

export function svgImportPlugin(): Plugin {
  const cache = new Map<string, CacheEntry>();

  return {
    name: 'svg-import',
    setup(build) {
      // 1. Intercept any import ending in .svg plus an optional query.
      build.onResolve({ filter: /\.svg(\?(inline|dataurl|component))?$/ }, (args) => {
        const [rawPath, rawQuery = ''] = args.path.split(/(?=\?)/, 2);
        const kind = kindFromQuery(rawQuery) ?? 'inline'; // bare import -> inline string
        // Resolve relative to the importing file's directory.
        const absPath = resolve(dirname(args.importer), rawPath);
        return {
          path: absPath,
          namespace: 'svg',
          // Carry the requested kind forward; never re-parse the query in onLoad.
          pluginData: { kind } as { kind: SvgKind },
        };
      });

      // 2. One onLoad for the whole namespace. Branches on pluginData.kind.
      build.onLoad({ filter: /.*/, namespace: 'svg' }, async (args) => {
        const kind = (args.pluginData as { kind: SvgKind }).kind;
        const cacheKey = `${args.path}\0${kind}`;

        // Cache check: reuse only if the file's mtime is unchanged.
        const stats = await stat(args.path);
        const cached = cache.get(cacheKey);
        if (cached && cached.mtimeMs === stats.mtimeMs) {
          return cached.result;
        }

        const svg = await readFile(args.path, 'utf8');
        let result: OnLoadResult;

        if (kind === 'component') {
          result = {
            contents: svgToComponent(svg),
            loader: 'jsx',
            // Watch the source so context().watch() rebuilds on edit.
            watchFiles: [args.path],
          };
        } else if (kind === 'dataurl') {
          result = {
            contents: `export default ${JSON.stringify(svgToDataUri(svg))};`,
            loader: 'js',
            watchFiles: [args.path],
          };
        } else {
          // 'inline' -> the raw markup as a default-exported string.
          result = {
            contents: `export default ${JSON.stringify(svg)};`,
            loader: 'js',
            watchFiles: [args.path],
          };
        }

        cache.set(cacheKey, { mtimeMs: stats.mtimeMs, result });
        return result;
      });

      // 3. Drop the entire cache when a watch rebuild starts, as a coarse
      //    safety net; the mtime check above handles the per-file case.
      build.onStart(() => {
        // Keep entries — mtime guards correctness — but prune if it grows large.
        if (cache.size > 5000) cache.clear();
      });
    },
  };
}

Wire it into a build, then into a watching context:

// build.mjs — esbuild 0.25.x, Node 20+
import { context } from 'esbuild';
import { svgImportPlugin } from './svg-import-plugin.ts';

const ctx = await context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  outdir: 'dist',
  jsx: 'automatic',            // React 17+ runtime; matches the component output
  plugins: [svgImportPlugin()],
});

await ctx.rebuild();           // one-shot build
await ctx.watch();             // incremental rebuilds on .svg edits
console.log('watching…');

Verification

Run the build and check the three outputs land in the bundle:

# esbuild 0.25.x
node build.mjs
node dist/index.js
# Expected stdout:
# true
# true
# function

To prove watch-mode invalidation works, edit the SVG fill and confirm a single rebuild fires without restarting the process:

# In a second shell, with `node build.mjs` still running:
printf '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 4h16v16H4z" fill="#bc4749"/></svg>' > src/assets/logo.svg
# The watching process logs a rebuild; dist/index.js now carries the new fill.

The mtimeMs guard means the second build reuses cache entries for any SVG you did not touch, so a 200-icon project rebuilds only the changed file.

Gotchas and Edge Cases

  • Query suffix breaks default resolution. esbuild treats logo.svg?inline as a literal path. The onResolve filter must include the optional (\?...)? group, or the bare import './x.svg' and the query forms will not both match. The plugin defaults a bare import to inline.
  • Watch ignores manually read files. esbuild only watches paths in the module graph it discovered through resolution. An asset you readFile inside onLoad is invisible to the watcher unless you return it in watchFiles. Omit it and edits silently produce stale bundles.
  • Caching without an mtime check goes stale. A Map keyed on path alone will serve the first-seen markup forever, including across watch rebuilds. Keying on path-plus-kind and validating against stat().mtimeMs is the cheapest correct invalidation; a content hash is stronger but adds a full read on every hit.
  • Naive SVG-to-JSX misses attributes. The regex transform here covers class, hyphenated attributes, and prop forwarding, but production SVGs with inline <style>, namespaced xlink: attributes, or <!-- comments --> need a real parser such as SVGO plus @svgr/core. Swap the body of svgToComponent for SVGR if your icons are not hand-authored.