Vite 18 ⚡ Path Aliases — Resolving @/ imports
Path aliases are a convenience that becomes a requirement as a project grows. The ../../../lib/utils import is fragile—move the file one directory deeper and every relative path in every consumer breaks. The @/lib/utils import is stable, readable, and independent of the file’s location in the tree. But Vite does not read tsconfig.json path mappings. It has no built-in awareness of TypeScript’s paths configuration . The editor resolves @/ because TypeScript tells it to; the dev server and the build fail with “Failed to resolve import” because Vite has never heard of the alias. The alias must be configured twice—once in TypeScript, once in Vite—and the two configurations must agree.
The dual configuration is the source of nearly every path alias bug. A developer adds "@/*": ["./src/*"] to tsconfig.json, sees the editor autocomplete work, and assumes the setup is complete. The dev server then fails to resolve the import, or the build fails with a Rollup error, or—worse—the type checker passes and the build fails, producing a failure that looks like a Vite bug but is a configuration gap . The two tools use the alias for different purposes: TypeScript uses it to resolve types and provide editor intelligence, Vite uses it to resolve modules during bundling and dev-time transformation. Both must know.
Key point: Vite does not read tsconfig.json path mappings. The @/ alias must be declared in vite.config.ts under resolve.alias in addition to the paths entry in tsconfig.json. If either is missing, either the editor or the build will fail.
Why path aliases need dual configuration
The TypeScript-Vite resolution split. TypeScript resolves imports for type checking and editor intelligence. It reads compilerOptions.baseUrl and compilerOptions.paths from tsconfig.json and maps the alias to a file location for the language server. Vite resolves imports for bundling and dev-time module loading. It reads resolve.alias from vite.config.ts and applies the mapping during the transform and build pipeline. These are separate resolution algorithms operating on separate configuration files, and neither reads the other’s configuration . A project with only the TypeScript configuration has working autocomplete and failing builds. A project with only the Vite configuration has working builds and failing type checking in the editor.
The baseUrl ambiguity. The baseUrl option in tsconfig.json changes how non-relative imports are resolved. When baseUrl is "." and paths is { "@/*": ["./src/*"] }, the alias maps to <project-root>/src/*. When baseUrl is "src", the same paths entry maps differently depending on the TypeScript version and the presence of the paths fallback. The Vite alias must mirror the effective resolution, not the literal string in paths. A mismatch between baseUrl semantics and the Vite replacement path produces a build that resolves the import to a file that does not exist.
The dependency pre-bundling trap. Vite’s dependency scanner runs before the alias resolution during pre-bundling. When it encounters an import like import("@/components/Button"), it may misinterpret the @ prefix as an npm scope—@/components looks like a package name—and attempt to pre-bundle it as an external dependency . The scan fails because the package does not exist in node_modules, and the dev server may skip pre-bundling or produce a 404 at runtime. The optimizeDeps.exclude array is the escape hatch: it tells the scanner to leave the aliased paths alone . This is not part of the basic alias configuration, but it is the fix for the specific class of errors where the alias is defined correctly and the dev server still fails to load dynamic imports.
The trade-off. Path aliases add configuration surface. The tsconfig.json must be edited, the vite.config.ts must be edited, and the two must stay in sync when the source directory structure changes. The vite-tsconfig-paths plugin automates the synchronization by reading tsconfig.json and generating the Vite alias at build time , but it adds a dependency and a plugin to the pipeline. The manual configuration is two small edits; the plugin is one dependency that eliminates the risk of drift.
a. Configuring the alias in both files
The TypeScript configuration declares the alias for the editor and the type checker. The Vite configuration declares the alias for the bundler and the dev server. The paths must resolve to the same directory.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
The baseUrl is the project root and the paths entry maps @/ to ./src/. An import of @/lib/utils resolves to <root>/src/lib/utils.ts in the editor and the type checker. This configuration alone is insufficient for Vite.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// The alias key must match the tsconfig paths key.
// The replacement must be an absolute path.
'@': path.resolve(__dirname, './src'),
},
},
});
The path.resolve(__dirname, './src') produces an absolute path to the src directory. The @ key matches the @/ prefix in the import; Vite’s alias resolution handles the trailing slash implicitly. An import of @/lib/utils becomes <absolute-path-to-src>/lib/utils, which Vite resolves to the file .
The two configurations are now aligned. The editor resolves @/lib/utils through TypeScript’s paths. Vite resolves the same import through resolve.alias. The build passes, the type check passes, and the editor autocomplete works.
b. The dependency scanner workaround
The alias configuration is complete, but the dev server may still fail when a dynamically imported module uses the alias. The error is not a resolution failure; it is a pre-bundling failure. Vite’s dependency scanner treats the @ prefix as an npm scope and attempts to resolve it from node_modules before the alias is applied .
// vite.config.ts — with optimizeDeps.exclude
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
// Tell the scanner to skip these prefixes.
// This prevents the "@ is a package scope" misinterpretation
// during pre-bundling.
exclude: ['@'],
},
});
The exclude array accepts the alias prefix. When the scanner encounters @/components/Button, it skips pre-bundling for that path because the prefix is in the exclude list. The module is loaded through the normal dev server pipeline, where the alias is applied . This workaround is only necessary for dynamic imports that trigger the scanner. Static imports are resolved through the alias without the scanner interference.
c. The plugin alternative
The vite-tsconfig-paths plugin eliminates the dual configuration by reading tsconfig.json directly and generating the Vite alias at startup. The plugin is a Vite plugin, not a runtime dependency, and it synchronizes the alias without manual edits.
// vite.config.ts — with vite-tsconfig-paths
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
react(),
// The plugin reads tsconfig.json and applies the paths as Vite aliases.
// No manual resolve.alias needed for the paths declared in tsconfig.
tsconfigPaths(),
],
});
The plugin reads the paths and baseUrl from tsconfig.json and translates them into Vite’s resolve.alias format. The TypeScript configuration becomes the single source of truth. When the paths entry changes, the Vite alias changes with it . The trade-off is a plugin in the pipeline and a dependency in devDependencies. The manual configuration has no dependency but requires the developer to remember the second edit when the source structure changes.
Complete Example Session
// ============================================
// PART 1: THE TSCONFIG PATH MAPPING
// ============================================
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
// The editor and tsc now resolve @/lib/utils to src/lib/utils.ts.
// ============================================
// PART 2: THE VITE CONFIGURATION
// ============================================
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
// Vite now resolves @/lib/utils during dev and build.
// ============================================
// PART 3: THE OPTIMIZE DEPS WORKAROUND
// ============================================
// vite.config.ts — if dynamic imports fail
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
// Skip pre-bundling for the @ prefix.
exclude: ['@'],
},
});
// ============================================
// PART 4: THE PLUGIN ALTERNATIVE
// ============================================
// vite.config.ts — with vite-tsconfig-paths
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
react(),
tsconfigPaths(), // Reads tsconfig.json automatically
],
});
// ============================================
// PART 5: THE TYPESCRIPT NODE TYPES DEPENDENCY
// ============================================
// Using path.resolve requires Node.js types.
// Without @types/node, TypeScript reports an error on __dirname.
//
// npm install -D @types/node
// ============================================
// PART 6: MULTIPLE ALIASES
// ============================================
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"]
}
}
}
// vite.config.ts
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@utils': path.resolve(__dirname, './src/utils'),
},
}
// ============================================
// PART 7: THE BASEURL VARIANT
// ============================================
// Some projects use baseUrl: "src" and paths: { "@/*": ["./*"] }.
// The effective resolution is the same: @/ maps to src/.
// The Vite alias must still point to src/.
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
}
// ============================================
// PART 8: VERIFICATION
// ============================================
// 1. Type check: npx tsc --noEmit
// 2. Dev server: npm run dev, import @/lib/utils
// 3. Build: npm run build, verify no Rollup resolution errors
// 4. Preview: npm run preview, verify the built app loads
// 5. Editor: Ctrl+Click on @/lib/utils, verify it opens the file
// ============================================
// PART 9: THE SYMPTOM MATRIX
// ============================================
// Editor works, dev server fails:
// → tsconfig paths is set, vite resolve.alias is missing.
//
// Dev server works, editor fails:
// → vite resolve.alias is set, tsconfig paths is missing.
//
// Both work, build fails:
// → Alias replacement path is wrong (points to non-existent dir).
//
// Dynamic import 404s, static imports work:
// → optimizeDeps.exclude is missing the alias prefix.
// ============================================
// PART 10: THE IMPORT IN PRACTICE
// ============================================
// src/app/App.tsx
import { Button } from '@/components/Button';
import { formatDate } from '@/utils/date';
// Both resolve through the alias in the editor and in Vite.
// Moving Button.tsx to src/components/ui/Button.tsx
// does not break the import; the alias is stable.
The ten parts covered the dual configuration, the scanner workaround, the plugin alternative, the Node types dependency, the multiple alias pattern, and the symptom matrix. The through-line is that the alias is a two-file contract, and the failure modes are predictable once the contract is understood.
Quick Reference
Configuration comparison
| File | Option | Purpose | Required for |
|---|---|---|---|
tsconfig.json | compilerOptions.paths | Editor resolution, type checking | Editor autocomplete, tsc |
vite.config.ts | resolve.alias | Module resolution, bundling | Dev server, build |
vite.config.ts | optimizeDeps.exclude | Scanner bypass | Dynamic imports with alias |
Symptom matrix
| Symptom | Missing configuration |
|---|---|
| Editor works, dev server fails | resolve.alias in vite.config.ts |
| Dev server works, editor fails | paths in tsconfig.json |
| Both work, build fails | Incorrect replacement path |
| Static imports work, dynamic 404 | optimizeDeps.exclude missing prefix |
Alias configuration options
| Approach | Dependency | Sync risk |
|---|---|---|
| Manual dual config | @types/node | High (two files to edit) |
vite-tsconfig-paths | Plugin | Low (single source) |
Best Practices
✅ Do This:
// Configure both files with the same effective path
tsconfig: { "@/*": ["./src/*"] } // ✅
vite: { '@': path.resolve(__dirname, './src') } // ✅
// Install @types/node for path.resolve
npm install -D @types/node // ✅
// Use optimizeDeps.exclude for dynamic imports
optimizeDeps: { exclude: ['@'] } // ✅
// Verify with tsc --noEmit and a production build
npx tsc --noEmit && npm run build // ✅
❌ Don’t Do This:
// Configure only tsconfig.json and expect Vite to work
// Vite does not read tsconfig paths. // ❌
// Use a relative path in the Vite alias
'@': './src' // Must be absolute via path.resolve // ❌
// Assume the editor working means the build will work
// The editor uses TypeScript, not Vite. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Dev server fails after editor works | Only tsconfig.json configured | Add resolve.alias to vite.config.ts |
| Build fails after dev works | Alias replacement path incorrect | Verify path.resolve(__dirname, './src') |
| Dynamic import returns 404 | Scanner treats @ as package scope | Add optimizeDeps.exclude: ['@'] |
__dirname type error | @types/node not installed | npm install -D @types/node |
| Alias drift over time | Manual config in two files | Use vite-tsconfig-paths plugin |
Real-World Examples
1. TypeScript paths
"paths": { "@/*": ["./src/*"] }
2. Vite alias
resolve: { alias: { '@': path.resolve(__dirname, './src') } }
3. Multiple aliases
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
}
4. Scanner workaround
optimizeDeps: { exclude: ['@'] }
5. Plugin approach
plugins: [react(), tsconfigPaths()]
6. Type check verification
npx tsc --noEmit
7. Build verification
npm run build
8. Import in source
import { Button } from '@/components/Button';
9. Editor verification
// Ctrl+Click on @/components/Button should open the file.
10. Node types install
npm install -D @types/node
Visual
The Dual Configuration Contract
┌──────────────────────────────────────────────────────────────┐
│ ONE ALIAS, TWO RESOLVERS │
│ │
│ import { Button } from '@/components/Button' │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ TypeScript │ │ Vite │ │
│ │ │ │ │ │
│ │ Reads: │ │ Reads: │ │
│ │ tsconfig.json │ │ vite.config.ts │ │
│ │ │ │ │ │
│ │ paths: { │ │ resolve: { │ │
│ │ "@/*": │ │ alias: { │ │
│ │ ["./src/*"] │ │ "@": src │ │
│ │ } │ │ } │ │
│ │ │ │ } │ │
│ │ │ │ │ │
│ │ Resolves for │ │ Resolves for │ │
│ │ editor + tsc │ │ dev + build │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Neither reads the other's file. Both must be configured. │
└──────────────────────────────────────────────────────────────┘
The Symptom Matrix
┌──────────────────────────────────────────────────────────────┐
│ WHICH CONFIGURATION IS MISSING? │
│ │
│ Editor works? Dev server works? Missing │
│ ───────────── ───────────────── ─────── │
│ Yes No Vite resolve.alias │
│ No Yes tsconfig paths │
│ Yes Yes (build fails) Wrong replacement path │
│ Yes Yes (dynamic 404) optimizeDeps.exclude │
│ │
│ The editor uses TypeScript. The dev server uses Vite. │
│ They are independent resolvers. │
└──────────────────────────────────────────────────────────────┘
The Scanner Trap
┌──────────────────────────────────────────────────────────────┐
│ WHY DYNAMIC IMPORTS FAIL WITH ALIASES │
│ │
│ import('@/pages/Dashboard') │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Dependency scanner runs │ │
│ │ BEFORE alias resolution │ │
│ └──────────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Sees "@" prefix │ │
│ │ Thinks: npm scope package │ │
│ │ Searches node_modules │ │
│ └──────────────┬──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Package not found │ │
│ │ Pre-bundling fails │ │
│ │ 404 at runtime │ │
│ └─────────────────────────────┘ │
│ │
│ Fix: optimizeDeps.exclude: ['@'] │
│ Tells the scanner to skip these prefixes. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| TypeScript configuration | compilerOptions.paths in tsconfig.json |
| Vite configuration | resolve.alias in vite.config.ts |
| Vite alias replacement | Must be absolute (path.resolve) |
path.resolve dependency | @types/node (dev dependency) |
| Dynamic import fix | optimizeDeps.exclude: ['@'] |
| Plugin alternative | vite-tsconfig-paths |
| Verification | tsc --noEmit and npm run build |
Key takeaways:
- Vite does not read
tsconfig.json. The@/alias must be declared in both files. The editor andtscuse the TypeScript configuration; Vite uses theresolve.aliasconfiguration. Neither reads the other . - The Vite alias replacement must be an absolute path.
path.resolve(__dirname, './src')produces the absolute path. A relative string like'./src'does not resolve correctly in all contexts . - Dynamic imports may require
optimizeDeps.exclude. Vite’s dependency scanner runs before alias resolution and may misinterpret the@prefix as an npm scope. Adding the prefix tooptimizeDeps.excludeskips pre-bundling for those paths . vite-tsconfig-pathseliminates the dual configuration. The plugin readstsconfig.jsonand generates the Vite alias automatically. The trade-off is a plugin dependency in exchange for a single source of truth .- The symptom tells you which configuration is missing. Editor works but dev server fails: Vite alias missing. Dev server works but editor fails: TypeScript paths missing. Both work but build fails: wrong replacement path. Static imports work but dynamic 404s:
optimizeDeps.excludemissing.
Remember: A path alias is a promise that the same import string resolves to the same file in every tool that reads it. TypeScript and Vite are two different tools with two different resolution algorithms and two different configuration files. The alias works only when both files declare the same mapping. The editor autocomplete is not proof that the build will work. The dev server is not proof that the type check will pass. Configure both, verify both, and when a new alias is added, add it to both files before writing the first import that uses it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!