How to configure module resolution aliases in Vite

Error signature: The Vite dev server returns HTTP 404 on aliased imports such as import { config } from '@core/config', or vite build throws ERR_MODULE_NOT_FOUND during Rollup’s pre-bundling phase. Chrome DevTools Network tab shows the aliased specifier never resolving to a file URL; the Vite terminal prints failed to resolve import "@core/config".

This failure occurs at the intersection of two independent resolution systems — TypeScript’s type checker and Vite’s runtime ESM transformer — and it is rooted in the same static-vs-dynamic resolution split covered in Understanding ES Modules vs CommonJS in Bundlers.


Vite alias resolution flow diagram Shows how an aliased import passes through Vite's resolve.alias table to reach the Rollup resolver and the file on disk, while TypeScript separately uses tsconfig.json compilerOptions.paths for type checking. A mismatch between the two causes 404s in dev and ERR_MODULE_NOT_FOUND in production. source.ts import '@core/…' resolve.alias @core → src/core Rollup resolver (Vite 5+ native ESM) src/core/ index.ts tsconfig.json compilerOptions.paths TypeScript type checker only runtime runtime types only Mismatch between the two → 404 in dev, ERR_MODULE_NOT_FOUND in build

Root cause analysis

Vite runs two distinct resolution pipelines simultaneously. During development, the native ESM dev server intercepts every import specifier and rewrites it using the resolve.alias table defined in vite.config.ts. The rewritten path is then handed to Rollup’s resolver (backed by esbuild for pre-bundled dependencies). TypeScript’s language server — and tsc during type checking — uses compilerOptions.paths in tsconfig.json independently of Vite; it never reads vite.config.ts.

The result: if resolve.alias maps @core to src/core but tsconfig.json is missing or has a different mapping, TypeScript may show no errors while the dev server returns 404s. The reverse also happens — tsconfig paths correctly typed, but Vite cannot find the file at runtime.

A second failure mode involves Vite’s pre-bundle cache at node_modules/.vite/deps. When an alias is changed or a workspace symlink is updated, the cache can retain stale absolute paths, causing the dev server to serve an outdated ESM stub even after config edits. This is directly related to how Vite module graph and dependency resolution manages its cached transform state between restarts.

This page is a companion to Understanding ES Modules vs CommonJS in Bundlers, where the broader static ESM resolution model is explained in depth.

Exact config and CLI fix

Step 1 — Define resolve.alias in vite.config.ts (Vite 5+)

Use path.resolve() combined with fileURLToPath to produce absolute paths. Absolute paths eliminate ambiguity when Vite is invoked from a different working directory (e.g., inside a monorepo or a CI runner). The array form of resolve.alias is required when a regex find pattern must capture a wildcard group and substitute it into the replacement:

// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  resolve: {
    alias: [
      // Regex form: captures the subpath and substitutes into src/
      { find: /^@app\/(.*)/, replacement: path.resolve(__dirname, 'src/$1') },
      // Exact prefix form: simpler, no capture group needed
      { find: '@core', replacement: path.resolve(__dirname, 'src/core') },
      { find: '@utils', replacement: path.resolve(__dirname, 'src/utils') }
    ]
  }
});

The object shorthand (alias: { '@core': path.resolve(...) }) works for simple exact-prefix matching but cannot perform regex captures. Prefer the array form whenever you have more than two aliases, so the configuration remains consistent and readable.

Step 2 — Mirror paths in tsconfig.json

TypeScript will not pick up Vite’s resolve.alias entries. You must add matching compilerOptions.paths so the language server can resolve types correctly and tsc --noEmit can succeed in CI:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"],
      "@core": ["src/core"],
      "@core/*": ["src/core/*"],
      "@utils": ["src/utils"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

baseUrl is required when using paths; set it to "." (project root) so the path strings resolve relative to the tsconfig.json file. Without baseUrl, TypeScript 5 will emit a diagnostic error and ignore the paths block entirely.

Step 3 — Clear the pre-bundle cache

After any alias change, the Vite optimizer cache must be invalidated. The --force flag deletes node_modules/.vite/deps and forces a full re-optimization on next startup:

# Force cache regeneration — run after every resolve.alias change
npx vite --force

Alternatively, set optimizeDeps.force: true in vite.config.ts during a debugging session (revert before committing):

// vite.config.ts — Vite 5+ (temporary debug option)
export default defineConfig({
  optimizeDeps: {
    force: true  // Delete and regenerate node_modules/.vite/deps on every startup
  },
  resolve: {
    alias: [ /* ... */ ]
  }
});

Step-by-step verification

  1. DevTools Network tab (dev server): Start the dev server with npx vite. Open Chrome DevTools → Network → filter by “404”. Reload the page. Zero 404 responses on aliased module specifiers confirms Vite is resolving them correctly. Any remaining 404s will show the original unresolved specifier in the Request URL, indicating an alias entry is missing.

  2. HMR smoke test: Edit a file inside an aliased directory (e.g., src/core/config.ts). The terminal should log [vite] hmr update within 50 ms and the browser should reflect the change without a full page reload. A full reload instead of an HMR patch indicates the module graph lost track of the file’s position, often caused by a stale alias resolving to a different absolute path.

  3. Production build verification: Run npx vite build. Inspect the output for warnings containing "Could not resolve" or "failed to resolve import". Then open dist/assets/ and confirm no chunk contains an unresolved specifier literal like @core/config.

  4. Bundle analysis for duplicate chunks: Install rollup-plugin-visualizer and add it to your Vite config’s plugins array. After a production build, open stats.html and verify the aliased modules appear exactly once in the chunk graph. Duplicate entries indicate the resolver resolved the same module via two different paths — the aliased path and the raw relative path — creating two separate module instances.

    // vite.config.ts — Vite 5+ (add to plugins for visual analysis)
    import { visualizer } from 'rollup-plugin-visualizer';
    
    export default defineConfig({
      plugins: [
        visualizer({ filename: 'stats.html', open: true, gzipSize: true })
      ],
      resolve: { alias: [ /* ... */ ] }
    });
  5. TypeScript CI gate: Add tsc --noEmit to your CI pipeline. A clean type-check pass confirms tsconfig.json paths align with your source layout. If tsc reports "Cannot find module '@core/config'" while Vite builds successfully, the tsconfig paths block is missing or has a different prefix from the Vite alias.

Edge cases and gotchas

Gotcha 1 — Monorepo workspace cache staleness

In pnpm or Yarn Berry workspaces, internal packages are resolved through symlinks in node_modules/@workspace/. When a workspace package updates its package.json exports field or its dist/ output path changes, Vite’s pre-bundle cache does not automatically invalidate — the heuristic only checks package.json modification timestamps, not the contents of symlink targets.

Fix: add the affected workspace packages to optimizeDeps.include explicitly and use resolve.dedupe to force single-instance resolution:

// vite.config.ts — Vite 5+ (workspace patch)
export default defineConfig({
  resolve: {
    alias: [ /* ... */ ],
    dedupe: ['@workspace/ui', '@workspace/shared']
  },
  optimizeDeps: {
    include: ['@workspace/ui', '@workspace/shared'],
    force: true  // Remove after confirming stability
  }
});

If workspace symlinks continue to trigger stale resolution warnings, add the package to optimizeDeps.exclude instead. This forces Vite to serve the package’s source files directly as native ESM requests, bypassing the pre-bundle cache entirely. Be aware that excluded packages must already be valid ESM — packages that use require() internally will cause browser parse errors when served without pre-bundling. For a detailed walkthrough of this tradeoff, see Optimizing dev server startup times for large monorepos.

Gotcha 2 — CJS packages under an ESM alias

When an alias points to a path that re-exports a CommonJS dependency (for example, @utils resolves to src/utils/index.ts, which does export { default } from 'some-cjs-lib'), Vite’s dev server may serve the CJS interop wrapper instead of the aliased file. The symptom is an HMR update that succeeds but the imported value is undefined or a module object rather than the expected export.

Fix: ensure the target of the alias is a fully valid ESM file. If the aliased entry point must re-export a CJS package, add that CJS package to optimizeDeps.include so esbuild converts it to ESM during pre-bundling before the alias resolution chain runs:

// vite.config.ts — Vite 5+ (CJS dep under ESM alias)
export default defineConfig({
  resolve: {
    alias: [
      { find: '@utils', replacement: path.resolve(__dirname, 'src/utils') }
    ]
  },
  optimizeDeps: {
    include: ['some-cjs-lib']  // Pre-bundle the CJS dep so the alias target gets pure ESM
  }
});

Gotcha 3 — Alias order and prefix collision

When multiple aliases share a common prefix (e.g., @core and @core/auth), Vite evaluates the resolve.alias array in order and stops at the first match. An alias of @core defined before @core/auth will intercept all imports that begin with @core, including @core/auth, and redirect them to src/core/auth rather than to a separate auth package.

Fix: always list more-specific aliases before less-specific ones:

// vite.config.ts — Vite 5+ (correct alias ordering)
resolve: {
  alias: [
    // More specific first
    { find: '@core/auth', replacement: path.resolve(__dirname, 'packages/auth/src') },
    // Less specific second
    { find: '@core', replacement: path.resolve(__dirname, 'src/core') }
  ]
}

FAQ

Why does Vite resolve aliases differently in dev and production?

In dev mode Vite resolves aliases through its native ESM transform pipeline before sending responses to the browser. In production mode Rollup performs alias expansion during bundle graph construction. Both paths consume resolve.alias, but the pre-bundle cache (node_modules/.vite) also stores transformed paths from the esbuild optimizer. A stale cache can make dev succeed while production fails — always run vite --force after alias changes.

Do I need both tsconfig paths and resolve.alias?

Yes. TypeScript’s compiler only uses compilerOptions.paths for type checking and editor navigation — it does not instruct Vite where to find files at runtime. Vite uses resolve.alias exclusively for file resolution. If the two are out of sync, TypeScript will show no errors while the dev server returns 404s, or vice versa.

When should I use the array form of resolve.alias instead of the object form?

Use the array form (objects with find and replacement keys) when you need regex matching — for example to capture a wildcard path like @app/* and substitute the captured group into the replacement path. The object form only supports exact string prefix matching, which is sufficient for simple aliases like @coresrc/core.