Esbuild doesn’t resolve module aliases defined in your tsconfig.json or jsconfig.json by default, even if you’re using a bundler that does support them.

Let’s see how this plays out with a simple project structure:

.
├── src/
│   ├── components/
│   │   └── Button.js
│   └── index.js
├── package.json
├── tsconfig.json
└── esbuild.config.js

And the contents:

tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"]
    }
  }
}

src/components/Button.js:

export function Button() {
  return "<button>Click me</button>";
}

src/index.js:

import { Button } from '@components/Button';

document.getElementById('app').innerHTML = Button();

esbuild.config.js:

const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  platform: 'browser',
  format: 'esm',
}).catch(() => process.exit(1));

If you run node esbuild.config.js, esbuild will immediately error out with something like:

src/index.js:1:17: ERROR: Could not resolve " "@components/Button"

This happens because esbuild, by default, is a fast bundler, not a full-blown TypeScript compiler. While it can process TypeScript and JavaScript, it doesn’t automatically parse and understand your tsconfig.json’s paths or jsconfig.json’s paths for module resolution.

To make esbuild understand these aliases, you need to explicitly tell it about them using the alias option in its configuration. This option allows you to map one path to another, mimicking the behavior of paths in your JSON configuration files.

Here’s how you modify esbuild.config.js to include the alias option:

esbuild.config.js (updated):

const esbuild = require('esbuild');
const tsConfigPaths = require('tsconfig-paths');

// Load tsconfig.json to get the paths
const { compilerOptions } = require('./tsconfig.json');

// Create an esbuild plugin to handle the aliases
const tsConfigPathsPlugin = {
  name: 'tsconfig-paths',
  setup(build) {
    // Use tsconfig-paths to resolve the alias
    const resolveTsconfigPaths = tsConfigPaths.createMatchPath(
      compilerOptions.baseUrl,
      compilerOptions.paths
    );

    build.onResolve({ filter: /.*/ }, (args) => {
      // Check if the imported path is an alias
      const resolvedPath = resolveTsconfigPaths(args.path, undefined, undefined, ['.ts', '.tsx', '.js', '.jsx']);

      if (resolvedPath) {
        // Return the resolved path to esbuild
        return { path: resolvedPath };
      }
    });
  },
};

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  platform: 'browser',
  format: 'esm',
  plugins: [tsConfigPathsPlugin], // Add the plugin here
}).catch(() => process.exit(1));

And you’ll need to install tsconfig-paths:

npm install --save-dev tsconfig-paths
# or
yarn add --dev tsconfig-paths

Now, when you run node esbuild.config.js, esbuild will:

  1. See the import { Button } from '@components/Button'; in src/index.js.
  2. The tsConfigPathsPlugin will intercept this import.
  3. tsconfig-paths will consult your tsconfig.json and determine that @components/Button should be resolved to src/components/Button.
  4. The plugin will then return this resolved path (src/components/Button) to esbuild.
  5. Esbuild will proceed to bundle src/components/Button into dist/bundle.js.

The output dist/bundle.js will contain the compiled code, and your application will run as expected, with the alias correctly resolved.

The tsconfig-paths package is crucial here because it understands the exact logic for resolving paths defined in tsconfig.json and jsconfig.json. Esbuild itself doesn’t have this built-in parsing capability for these specific configuration files. By using tsconfig-paths within a custom esbuild plugin, you’re bridging that gap. The build.onResolve hook in esbuild allows you to intercept module resolution requests before esbuild tries to find them itself. Inside the hook, you can perform custom logic, like using tsconfig-paths, and then either return a resolved path or let esbuild continue its default resolution process.

The next hurdle you’ll likely encounter is handling environment variables or other build-time configurations that might be necessary for your application.

Want structured learning?

Take the full Esbuild course →