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.
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"
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.
Could not resolve on the query path is exactly why onResolve is required.- 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". The symptom is a hard build failure, not a warning. The root cause is that esbuild’s on-disk resolver appends the literal string?inlineto 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?inlinesuffix — if it does, the resolver never got a chance to strip it, which is precisely the gap the plugin fills. - Add
--log-level=debugto 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, checkingnode_modules), all of which fail because none of them exist on disk with a query attached. The plugin’s job inonResolveis to short-circuit that whole sequence: strip the query, resolve the real file relative to the importer, and stash the query foronLoadinpluginData. Once the plugin is in place, re-running with--log-level=debugshould show the resolution succeeding on the stripped path inside thesvgnamespace, which is how you confirm interception is actually happening rather than the default resolver quietly picking the file up. - 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 alternative — three namespacessvg-inline,svg-dataurl,svg-component— works, but it triples the number ofonLoadhandlers 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 discriminatedkindkeeps 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.
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:
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
- 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.