Writing an esbuild onResolve Plugin for Path Aliases
esbuild does not read tsconfig.json paths unless you point it at the tsconfig, and even then edge cases in prefix matching slip through — so this guide builds a small onResolve plugin that maps @/… and other aliases to real filesystem paths, deterministically and without an infinite resolution loop. It narrows the plugin API covered in esbuild plugin patterns to the single most common use of onResolve; for the wider engine context see esbuild & Turbopack Workflows.
Path aliases exist because deep relative imports are a maintenance liability. A file five directories down that reaches back up with ../../../../lib/utils breaks the moment either file moves, and the diff that moves it is unreadable. An alias like @/lib/utils decouples the import specifier from the physical directory depth: the same string works from anywhere in the tree, and refactors that shuffle folders no longer rewrite hundreds of import lines. TypeScript solved the editor half of this years ago with compilerOptions.paths, which is why almost every codebase already has aliases the type checker understands. The problem is that paths is a TypeScript-only convention. It is documentation for the language service, not a runtime or a bundler instruction, so any tool that is not the TypeScript compiler has to be taught the same mapping separately.
That gap is exactly where esbuild lands. esbuild is a bundler, not a type checker; it deliberately does the minimum type-aware work required to bundle fast. When it meets @/lib/utils it applies its Node-style resolution algorithm, and under that algorithm a specifier that does not start with ., .., or / is a bare import — a package to be found in node_modules. There is no @ package, so the build stops. Passing tsconfig: 'tsconfig.json' closes part of the gap because esbuild will then honour paths, but it inherits every quirk of that field (a mandatory baseUrl interaction, wildcard-only matching, no support for aliases you want outside the TypeScript project) and gives you no hook to intervene. A hand-written onResolve plugin is roughly forty lines, has zero dependencies, and puts the entire mapping under your control — which matters the day an alias needs to point somewhere tsconfig cannot express.
Where this sits in the pipeline is worth being precise about. Resolution is the first thing esbuild does with any import: given a specifier and the file that imported it, decide which file on disk it names. Only after resolution succeeds does esbuild load, parse, and transform the module. An onResolve plugin injects itself into that first step, before any bytes are read. Because it runs before the loader, it can redirect a specifier to a completely different file, a virtual namespace, or — as here — an absolute path that esbuild then treats as if you had typed it yourself.
How onResolve works under the hood
An onResolve callback is registered with two things: a filter regular expression and, optionally, a namespace. esbuild does not call your callback for every import — that would be ruinously slow on a large graph where most specifiers are ordinary relative paths. Instead it tests each specifier against the union of registered filters first, in native code, and only invokes the JavaScript callback for the specifiers that match. The filter is therefore a performance gate, not just a convenience: an anchored, specific pattern like /^(@\/|@$|react$)/ means the callback fires a handful of times per build instead of tens of thousands. This is why esbuild insists the filter be a real RegExp and not a predicate function — it has to run in Go without crossing the JS boundary.
When your callback does fire, it receives an args object describing the import: args.path is the raw specifier as written, args.importer is the absolute path of the file that contains the import, args.resolveDir is the directory resolution should be relative to, args.kind distinguishes an import statement from a require() or a CSS url(), and args.pluginData carries anything a previous resolve pass attached. Your job is to return one of three shapes. Return an object with a path and a namespace to say “resolution is finished, this is the file.” Return undefined (or nothing) to say “not mine, let the next plugin or esbuild’s default resolver handle it.” Return an object with errors to fail the build with a message you control.
The single most important detail is that returning a path does not end resolution for that path — it starts a new resolution for the file you named. If the path you return also matches your filter, esbuild calls your callback again on it, and you have built an infinite loop. The default namespace for a returned path is file, which is what you want for a real on-disk file; setting it explicitly is a habit worth keeping because it documents intent and because a returned path in a non-file namespace is handed to onLoad callbacks instead of the filesystem. The plugin in this guide returns absolute paths in the file namespace precisely so esbuild picks up its normal extension-resolution and node_modules walking from a clean, unambiguous starting point.
Callback ordering follows registration order across plugins. If two plugins register onResolve callbacks whose filters both match a specifier, esbuild calls them in the order the plugins appear in the plugins array, and the first one to return a non-undefined result wins. An alias plugin should generally sit early in the array so it can rewrite specifiers before a more general resolver claims them, but never so early that it shadows a plugin that legitimately owns a namespace.
Prerequisites & reproducible setup
# esbuild 0.25.x, Node 18+
mkdir alias-demo && cd alias-demo && npm init -y
npm install --save-dev esbuild@0.25.0
mkdir -p src/lib
echo "export const greet = () => 'hi';" > src/lib/utils.ts
echo "import { greet } from '@/lib/utils'; console.log(greet());" > src/main.ts
The setup deliberately reproduces the failure before fixing it. src/main.ts imports from @/lib/utils, src/lib/utils.ts exports the function it wants, and nothing yet maps @ to src. Pinning esbuild@0.25.0 matters because the plugin API surface — the args fields, the pluginData channel, the metafile shape — has been stable across the 0.2x line but the exact resolution defaults have shifted in earlier majors; a version comment on every code block keeps a copied snippet reproducible.
A build of src/main.ts with no alias handling fails immediately with Could not resolve "@/lib/utils", because esbuild treats @/lib/utils as a bare package name and looks for it in node_modules. The error is worth reading closely: esbuild reports the specifier it could not resolve and the importer it came from, and the absence of any “did you mean a relative path?” hint is the tell that it never even considered src/ — it went straight to the package-resolution branch and gave up when node_modules/@ did not exist.
@/ as relative on its own.Diagnosis workflow
-
Confirm the specifier shape. An
@/-prefixed import is an alias; a bare name is a package; a./path is relative. Only the first needs this plugin. This classification is not cosmetic — it decides which branch of esbuild’s resolver the specifier would otherwise take, and therefore what your plugin has to intercept. Grep the source for the alias prefix (grep -rn "@/" src/) to see the real surface area, and note any specifiers that only look like aliases: an npm scope such as@scope/pkgstarts with@too, which is why a naive/^@/filter would swallow legitimate packages. The filter in this guide anchors on@/and@$specifically so scoped packages fall through untouched. -
Check whether tsconfig alone would work. Passing
tsconfig: 'tsconfig.json'tobuild()makes esbuild readpaths, but it does not handle abaseUrloutside the project root or aliases you want independent of TypeScript. It also cannot express a non-wildcard alias that redirects to a specific file, and it silently ignorespathsentries whose target directory does not exist rather than erroring. If your only aliases are simple@/*wildcards that already live intsconfig.json, the built-in path is genuinely less code — reach for a plugin when you need targets outside the project, aliases the type checker should not know about (a test double swapped in only at build time), or a hard failure when a target is missing. -
Decide prefix vs exact.
@/*is a prefix map;react→ a single file is an exact map. Your matcher must handle both or you will mis-resolve one. The distinction is entirely about the remainder: a prefix match consumes the alias key and keeps whatever follows the slash, appending it to the target directory, whereas an exact match consumes the whole specifier and appends nothing. Conflating them is the bug behind the two most common symptoms — an exact alias that swallows a trailing path segment, or a prefix alias that drops one — so the matcher below tests the two kinds as separate, ordered branches rather than a single clever regex.
Annotated solution config
The plugin is a plain object with a name and a setup function. name is what shows up in build errors and in the metafile if the plugin injects one, so make it descriptive. setup(build) runs once when the build starts; inside it you register callbacks against the build object. Everything expensive — reading a config file, compiling the alias table — should happen here in setup, once, not inside the per-import callback that runs thousands of times.
The alias table is an ordinary object mapping a key to an absolute target, resolved once at module load with path.resolve so the callback never does path math against a relative base. Two entries demonstrate the two match kinds: @ is a prefix whose remainder is preserved, and react is an exact key that maps a whole specifier to a single directory. The filter /^(@\/|@$|react$)/ is the gate — it matches @/anything, the bare @, and exactly react, and nothing else, so the callback is never invoked for the vast majority of imports.
// build.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
import path from 'node:path';
const aliases = {
'@': path.resolve('src'), // prefix map: @/x → src/x
react: path.resolve('node_modules/react'), // exact map: whole specifier
};
const aliasPlugin = {
name: 'alias',
setup(build) {
// Anchored filter so we are only called for potential aliases.
build.onResolve({ filter: /^(@\/|@$|react$)/ }, (args) => {
// Skip specifiers we have already rewritten to avoid a resolve loop.
if (args.pluginData?.aliasResolved) return undefined;
for (const [key, target] of Object.entries(aliases)) {
if (args.path === key) {
return { path: target, namespace: 'file' }; // exact
}
if (args.path.startsWith(key + '/')) {
const rest = args.path.slice(key.length + 1);
return { path: path.join(target, rest), namespace: 'file' }; // prefix
}
}
return undefined; // not an alias — let esbuild handle it
});
},
};
await esbuild.build({
entryPoints: ['src/main.ts'],
bundle: true,
outfile: 'dist/out.js',
plugins: [aliasPlugin],
metafile: true,
});
Read the callback body as three ordered decisions. The pluginData?.aliasResolved guard is the loop breaker: because a returned path in this design cannot re-match the filter (the target is an absolute src/… path, not another @/… specifier), the guard is belt-and-braces, but it becomes essential the moment an alias legitimately points at another aliased location. The exact check (args.path === key) runs before the prefix check so react is matched whole and never treated as a prefix of react-dom. The prefix check (args.path.startsWith(key + '/')) tests for key followed by a separator — the + '/' is what stops @abc from matching the @ key — then slices off key.length + 1 characters to get the remainder and rejoins it onto the target with path.join, which normalises separators and collapses any .. segments. Returning undefined at the end is deliberate: a specifier that reached the callback via the filter but is not in the table (there are none here, but there could be after edits) must fall through to esbuild’s default resolver rather than error.
If your aliases already live in tsconfig.json, deriving the table from that file keeps the two definitions from drifting apart. The next block reads compilerOptions.paths and turns each wildcard entry into a prefix alias, so the plugin and the editor stay in lockstep from a single source:
// alias-from-tsconfig.mjs — esbuild 0.25.x, Node 18+
import { readFileSync } from 'node:fs';
import path from 'node:path';
// Build the alias table from tsconfig paths so it can never drift.
export function aliasesFromTsconfig(tsconfigPath = 'tsconfig.json') {
const raw = readFileSync(tsconfigPath, 'utf8');
// tsconfig allows comments/trailing commas; strip the common cases.
const json = JSON.parse(raw.replace(/\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'));
const { baseUrl = '.', paths = {} } = json.compilerOptions ?? {};
const base = path.resolve(path.dirname(tsconfigPath), baseUrl);
const table = {};
for (const [key, [target]] of Object.entries(paths)) {
// "@/*": ["src/*"] -> key "@" maps to <base>/src
const cleanKey = key.replace(/\/\*$/, '');
const cleanTarget = target.replace(/\/\*$/, '');
table[cleanKey] = path.resolve(base, cleanTarget);
}
return table;
}
Swapping const aliases = { … } for const aliases = aliasesFromTsconfig() makes tsconfig.json the single owner of the mapping; the plugin body is unchanged because it still consumes a plain key-to-target object. The trade-off is a hard dependency on the tsconfig being parseable — the two replace calls handle line comments and trailing commas but not block comments, so a project with elaborate JSONC should use a real JSONC parser instead of hand-rolled stripping.
react never gets a remainder appended.Verification
# esbuild 0.25.x — build then confirm the alias resolved to a real file.
node build.mjs
node dist/out.js # prints: hi
A successful run prints the module’s output and writes dist/out.js. To confirm the alias resolved to the path you intended rather than a lucky node_modules hit, inspect the metafile: every @/… import should appear in inputs as an absolute src/… path.
A passing runtime is necessary but not sufficient evidence. It is entirely possible for a build to succeed for the wrong reason — a stray node_modules/@ shim, a second copy of the file reachable by relative path, or a different plugin quietly claiming the specifier first. The metafile is the only authoritative record of where each import resolved, which is why metafile: true is set on the build and why verification reads it rather than trusting exit code zero. Capture and query it directly instead of eyeballing a large JSON blob:
// verify.mjs — esbuild 0.25.x, Node 18+
import * as esbuild from 'esbuild';
import { aliasPlugin } from './build.mjs'; // or inline the plugin
const result = await esbuild.build({
entryPoints: ['src/main.ts'],
bundle: true,
write: false, // we only want the metafile, not the output
metafile: true,
plugins: [aliasPlugin],
});
const inputs = Object.keys(result.metafile.inputs);
const leaked = inputs.filter((f) => f.includes('node_modules'));
if (leaked.length) {
console.error('Alias leaked to node_modules:', leaked);
process.exit(1);
}
console.log('Resolved inputs:', inputs);
The check is intentionally blunt: if any input path for a specifier that should have been aliased contains node_modules, the alias did not apply and the process exits non-zero. Wiring this into the build’s own verification step turns a silent mis-resolution into a loud, reproducible failure. For the demo project the expected inputs are src/main.ts and src/lib/utils.ts, both absolute and both under src/ — if utils.ts ever shows up anywhere else, the matcher is the first suspect.
Gotchas & edge cases
-
Resolution loops. The symptom is a build that hangs or blows the stack with a repeated resolve of the same specifier. The root cause is a callback that returns a path which itself matches the filter, so esbuild re-enters
onResolveon the result endlessly. The fix has two forms: make the returned path structurally incapable of re-matching (an absolutesrc/…path never matches a/^@\//filter, which is why this plugin is loop-free by construction), and, for aliases that legitimately chain, mark the result withpluginDataand short-circuit on it as the guard here does. Confirm the fix by adding aconsole.count(args.path)at the top of the callback and watching that each specifier is counted once, not unboundedly. -
Exact vs prefix precedence. The symptom is
react-domresolving to<react>/domorreactresolving to<react>/with a stray remainder. The root cause is testing the prefix branch before the exact branch, soreactmatches as a prefix ofreact-dom(or the exact key picks up an empty remainder). The fix is to order the checks so the exact equality test runs first and returns before thestartsWithtest is ever reached, exactly as the callback does. Confirm by resolving bothreactandreact-domin the metafile and checking each lands on its own package root. -
Windows path separators. The symptom is a build that passes on macOS and Linux but produces
Could not resolveon Windows CI. The root cause is string concatenation with a hard-coded/, which yields mixed-separator paths likeC:\proj\src/lib\utilsthat the Windows resolver rejects. The fix is to build every path withpath.joinorpath.resolve, which emit the platform-native separator; never interpolatekey + '/' + rest. Confirm by running the build on a Windows runner, or locally by asserting the returned path containspath.sepand no foreign separator. -
tsconfig drift. The symptom is the editor jumping to one file on cmd-click while the bundle pulls in another, or a type error on an import that builds fine. The root cause is two independent definitions of the same alias —
tsconfig.jsonpathsand the plugin table — that have silently diverged. The fix is to keep exactly one source of truth: derive the plugin table fromtsconfig.jsonas shown above, or generate the tsconfigpathsfrom the plugin table, but never maintain both by hand. Confirm by asserting in a test that the two maps are equal, so a change to one that is not mirrored in the other fails CI rather than surfacing as a confusing editor-versus-build mismatch.
Related
- esbuild plugin patterns — the full onResolve/onLoad model this plugin is one instance of.
- Virtual modules in esbuild with onLoad — the onLoad counterpart for synthesized modules.
- Debugging Turbopack module resolution errors — the same alias problem on the Turbopack side.