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.

The problem exists because the loader map is a per-extension global. You set .svg=text or .svg=dataurl once for the whole build; you cannot say “this import wants markup, that one wants a URL, this third one wants a component.” A large app genuinely needs all three at once: an icon that is recolored via currentColor wants to be inlined into the DOM so CSS can reach its fill; a background decoration wants a data: URI so it stays a single cacheable string in a stylesheet; an interactive glyph that takes props wants to be a real component. Without per-import control you end up maintaining three copies of every asset, or you ship a runtime fetch that defeats the point of bundling.

Where this sits in the pipeline matters. The suffix is read during resolution, before any bytes are read from disk, so the decision about output shape is made once per import site and then carried as metadata through onLoad. That ordering is what lets a single file on disk become three different modules in the same bundle without three copies of its contents in memory. Get the ordering wrong — parse the query in onLoad instead of onResolve — and esbuild will have already failed to resolve the query-suffixed path, so onLoad never runs. The rest of this guide is largely about respecting that ordering and the caching it enables.

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. Pinning the exact patch version is not fussiness: esbuild’s plugin API is stable across 0.25.x, but OnLoadResult field names and the resolution filter semantics have shifted between minor lines in the past, and a plugin that returns watchFiles on a build older than 0.14 will silently do nothing. Treat the version comment at the top of every block below as part of the contract.

# 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 width="24" height="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"
One SVG, three import shapes by query suffix The query suffix selects the output: ?inline returns the raw markup string, ?dataurl returns a data image svg+xml URI, and ?component returns a React component. import './x.svg?…'one source ?inline → string ?dataurl → data: URI ?component → JSX
Figure: the query suffix on the import decides which of the three module shapes the plugin emits.

Diagnosis Workflow

Before writing transform logic, confirm where the default behavior breaks. Skipping this step is the most common reason people reach for a heavier tool than they need: they assume the query suffix is unsupported and give up, when in fact esbuild is telling them exactly which hook to implement. The three steps below reproduce the failure deliberately so the fix is obvious rather than guessed.

Why the query suffix forces a plugin esbuild treats logo.svg?inline as a different unresolvable path so a plain text loader fails to resolve; --log-level=debug shows the resolver failing; and the plugin's job is to strip the query, resolve the real file, and carry the kind in pluginData. logo.svg?inlineCould not resolve --log-level=debugresolver fails visibly onResolve strips querykind → pluginData The unresolvable query path is the signal you must intercept resolution
Figure: the Could not resolve on the query path is exactly why onResolve is required.
  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". The symptom is a hard build failure, not a warning. The root cause is that esbuild’s on-disk resolver appends the literal string ?inline to the path and then asks the filesystem for a file with that exact name, which does not exist. The fix is to intercept the path before the filesystem sees it. Confirm you have reproduced the right failure by checking that the unresolvable path in the error still carries the ?inline suffix — if it does, the resolver never got a chance to strip it, which is precisely the gap the plugin fills.
  2. Add --log-level=debug to watch the resolver try and fail on the query-suffixed path. The debug log prints each resolution attempt: the primary path, then the fallbacks it tries (adding extensions, checking node_modules), all of which fail because none of them exist on disk with a query attached. The plugin’s job in onResolve is to short-circuit that whole sequence: strip the query, resolve the real file relative to the importer, and stash the query for onLoad in pluginData. Once the plugin is in place, re-running with --log-level=debug should show the resolution succeeding on the stripped path inside the svg namespace, which is how you confirm interception is actually happening rather than the default resolver quietly picking the file up.
  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 alternative — three namespaces svg-inline, svg-dataurl, svg-component — works, but it triples the number of onLoad handlers and scatters the shared file-reading and caching logic across them, so an mtime bug fixed in one is missed in the other two. One namespace plus a discriminated kind keeps the read-once, transform-per-kind structure in a single place, which is the whole reason the cache below can be keyed uniformly.

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. Read the four helper functions first — kindFromQuery, svgToComponent, svgToDataUri, and the cache-entry shape — because each encodes a specific decision that the two hooks then lean on.

Plugin flow: resolve, cache-check, transform onResolve strips the query and tags the namespace with the kind in pluginData; onLoad checks a cache keyed on path plus kind against the file mtime, and on a miss reads the file and emits a string, data URI, or JSX component with watchFiles set. onResolvestrip query, tag ns cache by path+kindmtime guard emit string / URI / JSX+ watchFiles One onLoad branches on pluginData.kind; the mtime guard invalidates correctly
Figure: a single onLoad handles all three kinds, guarded by an mtime check so only edited files rebuild.
// 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…');

How It Works Under the Hood

The onResolve filter /\.svg(\?(inline|dataurl|component))?$/ runs against every import specifier in the graph, but it is a cheap regular expression, so the cost of testing it against non-SVG imports is negligible. When it matches, the split args.path.split(/(?=\?)/, 2) uses a lookahead so the delimiter is kept on the query side: ./x.svg?inline becomes ['./x.svg', '?inline'] rather than losing the ?. That is deliberate — kindFromQuery matches on the full ?inline token, so a stray split that dropped the ? would misclassify every import as the default inline kind. The resolved absolute path plus the namespace: 'svg' tag is what routes the module to the plugin’s onLoad instead of esbuild’s built-in loaders, and pluginData is the only channel that survives the hop from resolve to load.

Inside onLoad, the sequence is fixed: stat first, cache lookup second, readFile only on a miss. The reason stat precedes the cache check rather than following it is that the mtime is the cache key’s validity test — you cannot know whether the cached entry is fresh without the current mtime, and stat is roughly an order of magnitude cheaper than readFile for anything but the smallest icons. So on a warm cache the plugin does one stat and returns a memoized OnLoadResult object without touching the file body or re-running any transform. That is the mechanism behind the “200-icon project rebuilds only the changed file” claim: 199 icons cost one stat each, the changed one costs a stat plus a readFile plus a transform.

The transform functions are pure string operations by design. svgToDataUri percent-encodes only the five characters (#, %, {, }, <, >) that break inside an unquoted data: URL and collapses whitespace, which keeps the encoded payload close to the raw byte size instead of paying the roughly 33% inflation of base64. svgToComponent renames class to className, camel-cases hyphenated attributes so React accepts them, and splices {...props} onto the root element so callers can pass width, className, or event handlers. Both return strings that esbuild then feeds through the declared loader (js or jsx), so the plugin never has to understand JavaScript syntax — it hands esbuild source text and lets the normal pipeline parse it.

Where pluginData and namespaces come from

pluginData and namespace are not incidental — they are the two pieces of state esbuild lets a plugin attach to a module between hooks. A namespace is a label that says “this module is virtual as far as my plugin is concerned; do not run the default on-disk loader for it,” which is why the onLoad registration filters on { namespace: 'svg' } and matches every path in it with /.*/. pluginData is arbitrary per-module data; here it holds { kind }, computed exactly once in onResolve. Recomputing the kind in onLoad by re-parsing a query would be impossible anyway, because by the time onLoad runs the query has already been stripped from the path. This is the concrete reason the ordering discussed in the lead is load-bearing rather than stylistic.

Verification

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

Verify output and watch invalidation Running the built bundle prints true, true and function for the three import shapes; then editing the SVG fill triggers a single incremental rebuild while unchanged icons reuse the cache via the mtime guard. node dist/index.jstrue · true · function edit the SVG fillsingle rebuild unchanged iconscache hit (mtime)
Figure: a 200-icon project rebuilds only the file you touched — the mtime guard replays the rest.
# 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 width="24" height="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.

If the rebuild does not fire, the cause is almost always a missing watchFiles. esbuild’s watcher tracks the module graph it discovered through resolution; a file you opened with readFile inside onLoad is not in that graph unless you tell it. Confirm by adding a console.log(args.path) at the top of onLoad — if editing the SVG never re-triggers the log, the watcher is not observing the file and watchFiles is either absent or pointing at the wrong absolute path. If instead the log fires but the output is unchanged, the mtime guard is returning a stale entry, which means the filesystem reported an identical mtimeMs; some editors write in place with a preserved timestamp, and for those you need a content hash rather than mtime, discussed below.

One more thing worth verifying explicitly: that a bare import './x.svg' with no suffix still works and defaults to the inline string. Add a fourth line to src/index.ts, rebuild, and confirm it logs true:

// src/index.ts (append) — esbuild 0.25.x
import bare from './assets/logo.svg';                 // no suffix -> inline string
console.log(bare.startsWith('<' + 'svg'));            // true

This exercises the ?? 'inline' fallback in onResolve. If it throws Could not resolve, the filter’s optional query group is malformed and the bare form is not matching — the single most common typo when adapting the filter for a new extension.

Performance Considerations

The dominant cost in a build with hundreds of icons is not the regex filter or the transform — it is filesystem I/O. The plugin’s design deliberately reduces that to one stat per unchanged file per rebuild. Whether that is fast enough depends on the underlying filesystem: on a local SSD a stat is sub-millisecond, but inside a container with a bind-mounted volume or on a networked filesystem it can be one to two orders of magnitude slower, and at a few hundred icons the aggregate becomes visible in incremental rebuild times. If you are in that situation, a content-addressed cache that keys on a hash of the file contents read once at build start is worth the extra read, because it lets you skip both the stat and the transform on subsequent rebuilds within the same process.

The transform cost itself scales with SVG size, and the JSX path is the most expensive of the three because the regex passes are quadratic in the number of hyphenated attributes. For hand-authored icons this is noise. For machine-generated SVGs with thousands of nodes — exported illustrations, maps, charts — the string transform is the wrong tool regardless of speed, because it is not a parser and will corrupt some inputs; that case belongs to SVGR, covered below. A useful rule: keep this plugin for the icon set you control, and route large or third-party SVGs through a dedicated toolchain.

Memory is the other axis. The cache stores the full OnLoadResult — including the transformed contents string — for every path-plus-kind pair seen in the process lifetime. For a normal icon set that is a few hundred kilobytes and never a concern. The onStart prune at 5000 entries exists only to bound pathological cases such as a codemod that touches thousands of generated SVGs in one long-lived watch session; it is a safety net, not a tuning knob you should need to reach for.

When Not to Use This

Reach for a different approach when any of three things is true. First, if your SVGs are large or externally sourced, the regex-based svgToComponent will eventually mangle one — inline <style> blocks, xlink:href namespaced attributes, and <!-- comments --> all survive the transform unchanged and produce invalid JSX. Use @svgr/core there. Second, if you only ever need one output shape for the whole project, skip the plugin entirely and set the loader map: --loader:.svg=dataurl or =text is a single flag with zero maintenance, and the plugin’s value is precisely the per-import choice that a global loader cannot express. Third, if you are on a bundler with first-class SVG component support already — Vite with vite-plugin-svgr, Next.js with its SVGR integration — adding a hand-rolled esbuild plugin duplicates a maintained one; this guide is for esbuild-native pipelines where that integration does not exist.

Comparison with SVGR and the file Loader

Against esbuild’s built-in file loader, this plugin trades a network-cacheable external asset for an inlined one. The file loader emits a URL and copies the SVG to the output directory, so the browser fetches it separately and can cache it across pages; that is the right default for large or rarely-changing art. Inlining via ?inline or ?dataurl removes the extra request and lets CSS reach the markup, at the cost of embedding the bytes in every bundle that imports it — good for small, frequently-recolored icons, bad for anything you would not want duplicated across chunks.

Against SVGR, the difference is completeness versus weight. SVGR runs a real SVG parser (via svg-parser) and an AST transform, so it handles the full attribute surface, optimizes with SVGO, and supports features like turning title into an accessible label. This plugin’s svgToComponent is a deliberately minimal string rewrite with no dependencies, which is faster to run and trivial to audit but correct only for a constrained subset of SVG. The pragmatic split is to keep this plugin as the default fast path and delegate to SVGR for the ?component kind on inputs you do not author, which you can do by swapping the body of svgToComponent for a call into @svgr/core’s transform without touching the resolve/load/cache scaffolding.

CI Integration

In CI the watch machinery is irrelevant — you want a single deterministic build — so call ctx.rebuild() (or the one-shot build() API) and skip ctx.watch(). The cache still helps within that single process if the same SVG is imported under multiple kinds, but it does not persist across CI runs, and it should not: a fresh checkout gives every file a new mtime, so a cross-run cache keyed on mtime would be worse than useless. If your CI is slow specifically because of SVG volume, the fix is to hash contents into the build’s own content-addressable store rather than to persist this in-memory Map. Finally, make the build fail loudly on a transform error: the ?component path can produce invalid JSX for a malformed input, and you want esbuild’s parse error to break the pipeline in CI rather than ship a broken component, so do not wrap the onLoad body in a try/catch that swallows it.

Gotchas and Edge Cases

Four inline-SVG plugin edge cases The query suffix breaks default resolution so the filter must include the optional group; watch ignores manually-read files without watchFiles; caching without an mtime check goes stale; and naive SVG-to-JSX misses attributes so use SVGR for complex icons. query breaks resolution the filter must include the optional (\?…)? group; bare import defaults to inline. watch ignores read files a manually-read asset is invisible unless returned in watchFiles — else stale bundles. cache without mtime goes stale a path-only Map serves first-seen markup forever — key on path+kind, validate mtime. naive SVG-to-JSX misses attrs inline style, xlink: and comments need a real parser — swap in SVGR for complex icons.
Figure: one resolution trap, one watch trap, one caching trap, and one transform-completeness trap.
  • 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.