Path aliases
After five years building production systems across e-commerce, CRM, and SaaS platforms, I've seen how import paths grow into a mess. ../../../components becomes unreadable fast. Path aliases like @/components solve this by letting you reference modules from a fixed root, making your codebase cleaner and refactoring safer. Today I'll show you how to set them up correctly and avoid the pitfalls I've learned.
Configure paths in tsconfig
The foundation for path aliases lives in your tsconfig.json. This file tells TypeScript how to resolve your custom import paths. Without it, your IDE won't autocomplete, type-checking breaks, and you'll chase phantom errors.
Start with a basic setup. Open tsconfig.json and add the compilerOptions.paths object. I typically define at least three: @/* for app source, @/components/* if you want strict component-only imports, and @/utils/* for utilities.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/utils/*": ["src/utils/*"],
"@/types/*": ["src/types/*"],
"@/hooks/*": ["src/hooks/*"]
}
}
}
The baseUrl is critical. It tells TypeScript where to start resolving paths. Setting it to . means the project root. Then each entry in paths maps a pattern to real directories.
I worked on a Next.js e-commerce platform where we didn't set baseUrl initially. Developers got autocomplete in their editor but the build failed silently. Took two hours to debug. Don't make that mistake.
If you're using TypeScript 5.4+, consider the rootDirs option too, especially if you have monorepos or complex folder structures. But for most projects, baseUrl and paths alone are enough.
Match aliases in your bundler
Here's where I see teams trip up: TypeScript knows about your aliases, but your bundler doesn't. You'll get perfect IDE hints and zero type errors, then the app crashes at runtime. The bundler—whether Webpack, Vite, or esbuild—needs matching configuration.
For Vite (which I prefer for speed), use the resolve.alias config:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@/components': path.resolve(__dirname, './src/components'),
'@/utils': path.resolve(__dirname, './src/utils'),
},
},
})
For Next.js, the good news is that Next.js respects your tsconfig.json paths automatically. You don't need to duplicate configuration in next.config.js. Just set tsconfig.json correctly and you're done. I've used this on three SaaS projects—it works reliably.
For Webpack (if you're using Create React App or a custom setup), use the resolve.alias option:
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/'),
'@/components': path.resolve(__dirname, 'src/components/'),
},
},
}
The pattern is consistent: bundlers need to map your alias prefix to the actual file system path. Mismatch the two, and you'll debug why import { Button } from '@/components' works in development but fails in production.
On a CRM project, a junior dev forgot to update Webpack config when adding new aliases. The app built and ran locally fine, but CI/CD caught it. We added an alias config sync check to our pre-commit hooks after that.
Keep aliases few and clear
Resist the urge to alias everything. I've seen codebases with 15+ aliases. It becomes a guessing game: is it @/models, @/data, @/entities, or @/domain?
Stick to a minimal, intentional set. My rule of thumb from years of maintaining production code:
@/*– catch-all for your app source@/components– UI components (optional, only if you want strict imports)@/utils– utility functions@/types– shared TypeScript types@/hooks– custom React hooks (if React-heavy)
With the catch-all @/*, you can write import { apiClient } from '@/lib/api' without a dedicated @/lib alias. Fewer rules, less confusion.
I led a refactor of a trading platform's codebase that had aliases for @/models, @/entities, @/domain, and @/schemas. We consolidated to @/types. Onboarding time dropped noticeably.
Document your aliases somewhere visible—a DEVELOPMENT.md or a line in your README. New team members won't have to reverse-engineer your intent.
Use package names in monorepos
Monorepos add complexity. If you have multiple packages (e.g., packages/web, packages/api, packages/shared`), using generic @/* across all of them leads to conflicts and confusion.
Instead, use your package name as the alias. I did this on a travel platform with separate web, mobile, and backend packages:
// packages/web/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@travel-web/*": ["src/*"]
}
}
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@travel-shared/*": ["src/*"]
}
}
}
Then in your web package, import shared utilities like this:
import { formatDate } from '@travel-shared/utils'
import { Layout } from '@travel-web/components'
This makes it crystal clear which package you're importing from. It's especially valuable when debugging dependency issues or enforcing architectural boundaries.
If you're using a monorepo tool like Turbo or Nx, they often have built-in path alias setup. Nx, for example, automatically generates aliases based on your workspace structure. Use those conventions—don't reinvent them.
Common mistakes
I've made or seen every one of these:
- Forgetting baseUrl. Your IDE works, your build fails. Always set
baseUrlintsconfig.json. - Mismatched bundler config. TypeScript and Webpack disagreeing on where
@/componentspoints. Test in production, not just dev. - Aliases that duplicate your folder structure. If you have
src/components/Button, don't create both@/componentsand@/components/Buttonaliases. Use the parent and let the file path do the work. - Circular imports hiding in aliases. It's easier to create cycles when imports are terse. Use ESLint's
no-cyclerule alongside aliases. - Adding aliases without updating CI/CD. Locally built fine, but your GitHub Actions or Jenkins config doesn't know about them. Version your bundler config and test it in CI.
- Hoarding aliases. Twenty aliases make onboarding harder, not easier. Simplify ruthlessly.
Key takeaways
- Set
baseUrlandpathsintsconfig.json—TypeScript won't resolve aliases without them. - Mirror your TypeScript aliases in your bundler (Vite, Webpack, esbuild). Mismatch breaks runtime.
- Keep your alias set small and intentional. A catch-all
@/*usually suffices; avoid redundant sub-aliases. - In monorepos, use package names as aliases to clarify cross-package imports and prevent collisions.
- Test alias resolution in production builds and CI/CD, not just locally. Add config checks to pre-commit hooks.
- Document your aliases for new team members. One line in your dev guide saves confusion later.






Comments
Loading comments…