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.
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:
- Run a build with no plugin and a
textloader:esbuild src/index.ts --bundle --loader:.svg=text --outdir=dist. The query suffixes (?inline) make esbuild treatlogo.svg?inlineas a different, unresolvable path — you getCould not resolve "./assets/logo.svg?inline". That error is the signal that you must intercept resolution yourself. - Add
--log-level=debugto watch the resolver try and fail on the query-suffixed path. The plugin’s job inonResolveis to strip the query, resolve the real file, and stash the query foronLoad. - Decide the namespace. Use one namespace (
svg) and carry the requested kind inpluginData, so a singleonLoadcallback 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?inlineas a literal path. TheonResolvefilter must include the optional(\?...)?group, or the bareimport './x.svg'and the query forms will not both match. The plugin defaults a bare import toinline. - Watch ignores manually read files. esbuild only watches paths in the module graph it discovered through resolution. An asset you
readFileinsideonLoadis invisible to the watcher unless you return it inwatchFiles. Omit it and edits silently produce stale bundles. - Caching without an mtime check goes stale. A
Mapkeyed on path alone will serve the first-seen markup forever, including across watch rebuilds. Keying on path-plus-kind and validating againststat().mtimeMsis 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>, namespacedxlink:attributes, or<!-- comments -->need a real parser such as SVGO plus@svgr/core. Swap the body ofsvgToComponentfor SVGR if your icons are not hand-authored.
Related
- Custom Loaders and Asset Handling in esbuild — the parent guide on loader maps and the onResolve/onLoad resolution order this plugin extends.
- esbuild API and CLI for Rapid Builds — the
context()watch API the plugin uses for incremental rebuilds. - esbuild & Turbopack Workflows — the broader overview of esbuild-based build pipelines.