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.

An aliased specifier rewritten to an absolute path The specifier @/lib/utils enters onResolve, the plugin matches the @/ prefix and rewrites it to an absolute src/lib/utils path in the file namespace, and esbuild then resolves the extension and loads the module normally. Prefix match, then hand esbuild a real path @/lib/utilsaliased specifier onResolve prefix match@/ → ./src/ /abs/src/lib/utils.tsfile namespace esbuild resolves the extension and loads normally from here
Figure: the plugin only rewrites the path; extension resolution and loading stay with esbuild's defaults.

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.

Why the unaliased build fails Without a plugin esbuild classifies @/lib/utils as a bare package name, searches node_modules, finds nothing, and reports Could not resolve; the plugin's job is to reclassify it as a relative path before that search. @/lib/utilslooks like a package search node_modulesno @ package Could not resolve the alias is misclassified as a bare import
Figure: the failure is a classification problem — esbuild never treats @/ as relative on its own.

Diagnosis workflow

  1. 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/pkg starts 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.

  2. Check whether tsconfig alone would work. Passing tsconfig: 'tsconfig.json' to build() makes esbuild read paths, but it does not handle a baseUrl outside 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 ignores paths entries whose target directory does not exist rather than erroring. If your only aliases are simple @/* wildcards that already live in tsconfig.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.

  3. 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.

Prefix maps versus exact maps A prefix alias like @/* maps any specifier starting with @/ to a directory and keeps the remainder of the path, whereas an exact alias like react maps one whole specifier to one file; the matcher must distinguish the two so it does not append a remainder to an exact map. two match kinds, one matcher prefix: @/*@/lib/x → ./src/lib/xkeep the remainder exact: reactreact → ./node_modules/reactno remainder
Figure: appending a remainder to an exact map is the classic bug — the matcher must know which kind it hit.

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.

The three decisions inside the callback Inside onResolve the plugin first checks for an exact key match and returns the target, then checks for a prefix match and joins the remainder, and otherwise returns undefined so esbuild's default resolver handles the specifier. exact match?return target prefix match?join remainder neither?return undefined exact before prefix, fall through last
Figure: order matters — test the exact key before the prefix so 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.

Confirming the alias in the metafile inputs A correct build shows the aliased specifier appearing in the metafile inputs as an absolute src path; if it instead appears under node_modules or is missing, the alias did not apply and the matcher needs fixing. inputs: src/lib/utils.tsalias applied ✓ inputs: node_modules/@…matcher wrong ✗ the metafile is the source of truth for where it resolved
Figure: a passing runtime is not proof — the metafile confirms the import resolved through your alias and not by accident.

Gotchas & edge cases

Four alias-plugin edge cases A returned path that re-matches the filter causes an infinite resolution loop; matching a prefix before an exact key mis-resolves react as a prefix of react-dom; string concatenation with forward slashes breaks on Windows; and tsconfig paths drifting from resolveAlias makes the editor and build disagree. resolution loop→ return a non-re-matching path exact vs prefix order→ test exact key first Windows separators→ path.join, never string + tsconfig drift→ keep paths == resolveAlias
Figure: the loop and the exact/prefix ordering are the two that produce the most baffling failures.
  • 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 onResolve on the result endlessly. The fix has two forms: make the returned path structurally incapable of re-matching (an absolute src/… path never matches a /^@\// filter, which is why this plugin is loop-free by construction), and, for aliases that legitimately chain, mark the result with pluginData and short-circuit on it as the guard here does. Confirm the fix by adding a console.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-dom resolving to <react>/dom or react resolving to <react>/ with a stray remainder. The root cause is testing the prefix branch before the exact branch, so react matches as a prefix of react-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 the startsWith test is ever reached, exactly as the callback does. Confirm by resolving both react and react-dom in 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 resolve on Windows CI. The root cause is string concatenation with a hard-coded /, which yields mixed-separator paths like C:\proj\src/lib\utils that the Windows resolver rejects. The fix is to build every path with path.join or path.resolve, which emit the platform-native separator; never interpolate key + '/' + rest. Confirm by running the build on a Windows runner, or locally by asserting the returned path contains path.sep and 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.json paths and the plugin table — that have silently diverged. The fix is to keep exactly one source of truth: derive the plugin table from tsconfig.json as shown above, or generate the tsconfig paths from 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.