TypeScript Path Aliases: Clean Imports That Actually Work in Next.js

Technical PM + Software Engineer
Most import-path pain in TypeScript projects is self-inflicted.
A project starts clean enough. Then folders deepen, relative imports stretch across four or five ../ hops, and simple refactors turn into path archaeology. That is usually when someone reaches for aliases like @/components/Button and assumes the problem is solved.
It is not solved. It is only relocated.
The real headache is not creating a path alias. The real headache is making every tool in the stack agree on what that alias means. TypeScript might compile happily while Jest breaks, ESLint complains, or runtime resolution falls apart after a config move.
That is why path aliases only feel good when they are treated as a system, not a one-line tsconfig trick.
This article walks through the setup that actually holds up in a Next.js project: tsconfig paths, the @/* convention, how Next.js resolves aliases, why Jest fails without moduleNameMapper, what ESLint needs, and the mistakes that turn aliases into another debugging tax.
By the end, you should be able to set aliases once, keep the toolchain aligned, and know when aliases improve clarity versus when they just hide messy boundaries.
Why Relative Imports Get Ugly So Fast
Relative imports work fine until project depth starts competing with readability.
A line like this is technically correct:
import { formatDate } from "../../../../lib/format/date";
The problem is not that TypeScript cannot read it. The problem is that humans stop parsing it cleanly.
That kind of import leaks directory structure into every file. It also makes refactors more annoying because moving one file can trigger a cascade of fragile path updates.
Aliases help because they let imports describe logical roots instead of traversal steps.
import { formatDate } from "@/lib/format/date";
That is better, but only if @ actually means the same thing everywhere.
In plain English: aliases are useful because they reduce mental overhead, not because they look clever.
What Path Aliases Really Are
A path alias is just an agreed shorthand that maps an import prefix to a real location in the project.
In TypeScript, this usually happens through baseUrl and paths in tsconfig.json.
A common Next.js setup looks like this:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
That tells TypeScript to interpret imports starting with @/ as files under src/.
So these become equivalent from the compiler's point of view:
import { Button } from "@/components/Button";
import { Button } from "./src/components/Button";
The alias is not magical. It is just a mapping.
That matters because every tool that reads imports needs either direct support for that mapping or its own matching configuration.
A Clean Next.js Project Structure for Aliases
Aliases work best when they reinforce a real project boundary.
For example:
src/
app/
components/
lib/
hooks/
styles/
Then @/components, @/lib, and @/hooks all point into a coherent root.
This is much cleaner than creating a dozen micro-aliases too early, like:
@components/*@hooks/*@utils/*@styles/*
You can do that, but most teams do not need it at first.
A single @/* alias is usually the right boring default because it is easy to remember and hard to misread.
In plain English: aliases should simplify the mental model, not multiply it.
The Next.js Part That Usually Just Works
In modern Next.js projects, TypeScript path aliases are supported directly when configured in tsconfig.json or jsconfig.json.
That means the framework build pipeline generally respects the alias without extra webpack ceremony. In a typical app, once this is configured:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
imports like this work in app code:
import { SiteHeader } from "@/components/site-header";
import { getUser } from "@/lib/auth/get-user";
That is the easy part.
The harder part is remembering that Next.js is not the only tool interpreting your imports.
Why Jest Breaks Even When the App Runs Fine
This is one of the most common alias surprises.
Your app compiles. Your editor is happy. Then tests fail with something like:
Cannot find module '@/lib/auth/get-user'
That happens because Jest does not automatically inherit TypeScript path resolution just because tsconfig has it.
You need to map the alias for Jest explicitly.
A typical setup in jest.config.ts looks like this:
import type { Config } from "jest";
const config: Config = {
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
}
};
export default config;
That regex is the missing bridge. Without it, Jest sees the alias as just a string, not a path rule.
If you use multiple aliases, each one needs to be mapped clearly.
In plain English: if the app works and Jest fails, the alias setup is only half done.
ESLint Needs to Understand Resolution Too
The next common annoyance is linting.
You may see false-positive import errors even though TypeScript and Next.js are resolving aliases correctly.
That usually means the import resolver ESLint is using does not know about your tsconfig paths.
If you are using eslint-plugin-import, the fix often looks like this:
module.exports = {
settings: {
"import/resolver": {
typescript: {
project: "./tsconfig.json"
}
}
}
};
The exact config shape depends on your ESLint setup, but the principle is the same: linting needs to resolve modules through the same project config the compiler uses.
Otherwise you end up with noise like "unresolved import" for code that is actually valid.
The Mistakes That Make Aliases Feel Broken
Most alias pain comes from a few recurring mistakes.
1. Setting paths without baseUrl
If baseUrl is missing or inconsistent, resolution becomes harder to reason about.
2. Mapping aliases to the wrong root
If the alias says @/* -> ./src/* but some files still assume @ means project root, confusion spreads quickly.
3. Forgetting test and lint tooling
The app works, but the surrounding toolchain disagrees.
4. Over-aliasing too early
If every folder gets its own alias, imports start reading like internal DNS entries.
5. Using aliases to hide messy boundaries
An alias can make a bad dependency graph look cleaner than it really is.
That last one matters more than people think.
If a component deep in features/checkout is importing half the app through aliases, the alias is not helping architecture. It is just making the coupling prettier.
A Good Default Setup for Next.js
If you want a clean baseline that works in most TypeScript + Next.js apps, this is usually enough:
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "es2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
jest.config.ts
import type { Config } from "jest";
const config: Config = {
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
}
};
export default config;
ESLint resolver
settings: {
"import/resolver": {
typescript: {
project: "./tsconfig.json"
}
}
}
This is not exotic, and that is the point. You want alias setup to feel boring.
When Aliases Actually Help
Path aliases are genuinely useful when they:
- reduce long fragile relative imports,
- make imports read from a clear app root,
- support stable project organization,
- and improve refactor ergonomics.
They are especially helpful in larger Next.js apps where src/ is a real boundary and teams need shared conventions.
A good import becomes easier to scan:
import { DashboardShell } from "@/components/dashboard/shell";
import { getSession } from "@/lib/auth/session";
import { formatCurrency } from "@/lib/format/currency";
That reads like structure, not file traversal.
When Aliases Do Not Actually Help
Aliases are less helpful when they are used to blur ownership.
For example, if every feature imports from every other feature through @/, the project may feel cleaner while becoming harder to reason about.
You should still be able to answer:
- which modules are shared,
- which modules are feature-local,
- which modules are safe to depend on broadly,
- and which dependencies are starting to sprawl.
Aliases do not replace architecture. They only change how paths are written.
In plain English: a clean import string does not automatically mean a clean dependency boundary.
A Practical Migration Approach
If you already have a messy project full of relative imports, do not try to rewrite everything in one pass.
A safer sequence is:
- add the alias to
tsconfig, - update Jest and ESLint,
- verify runtime resolution in Next.js,
- start using aliases in touched files,
- run a codemod or targeted replacements later if needed.
That approach avoids turning a small configuration improvement into a noisy giant diff.
It also gives you time to notice where aliases are actually helping and where they are masking questionable module boundaries.
Final Takeaway
TypeScript path aliases are not hard because the compiler is difficult. They are hard because the whole toolchain has to agree.
In a Next.js project, the winning setup is usually simple:
- one clear alias root,
- matching
tsconfigpaths, - matching Jest mapping,
- matching ESLint resolution,
- and restraint about how many aliases you invent.
If you do that, aliases stop being a source of mysterious breakage and start doing the job they are supposed to do: make imports clearer and refactors less annoying.
After reading this, you should be able to set up @/* aliases in a Next.js TypeScript project, keep tests and linting aligned, and avoid the configuration drift that makes alias-based imports feel flaky instead of helpful.