DEV Community

Cover image for Fix tsconfig Paths Not Working in Next.js
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

Fix tsconfig Paths Not Working in Next.js

You write import { Button } from '@/components/Button' and one of two errors appears. In the editor or from tsc:

Cannot find module '@/components/Button' or its
corresponding type declarations. ts(2307)
Enter fullscreen mode Exit fullscreen mode

Or the editor is perfectly happy, and next build fails instead:

Module not found: Can't resolve '@/components/Button'
Enter fullscreen mode Exit fullscreen mode

These look like the same bug. They are not. The first means TypeScript's type checker cannot resolve the alias; the second means the bundler cannot. And that split is the whole story: compilerOptions.paths is not one setting that every tool obeys — it is a promise you make to the type checker, which every other tool in your stack has to be told about separately. The TypeScript documentation for paths says this outright: the feature "does not change how import paths are emitted by tsc, so paths should only be used to inform TypeScript that another tool has this mapping and will use it at runtime or when bundling".

In a typical Next.js project, four resolvers each need to agree on what @/* means: the Next.js bundler, the TypeScript language server, Jest, and ESLint. Threads such as vercel/next.js discussion #19891 (ESLint flagging aliases the build accepts) and #32141 (renamed alias breaking the build) are almost always one resolver falling out of sync with the others.

The 60-second checklist

Run through these in order before touching anything else. In practice one of them is the culprit far more often than any exotic cause.

  1. Restart the dev server. next dev reads tsconfig.json at startup. Edits to paths do not hot-reload — stop the process and start it again.
  2. Restart the TS server. In VS Code: command palette → TypeScript: Restart TS Server. The editor caches the old config independently of Next.js.
  3. Check the mapping actually points somewhere. "@/*": ["./src/*"] only works if the file lives under src/. If your project has no src/ directory, the mapping must be "@/*": ["./*"].
  4. Check baseUrl vs the target paths. With "baseUrl": "src/", entries like "components/*" are relative to src/. Without baseUrl (legal since TypeScript 4.1 — see below), entries are relative to the tsconfig.json file, so they should start with ./.
  5. Confirm the importing file is inside include. A file that tsconfig's include globs never match is type-checked (if at all) without your compilerOptions, so the alias is unknown there.
  6. Check which tool is complaining. Red squiggle only → TS server or ESLint. Build failure only → bundler config. Test failure only → Jest. Each has its own fix, below.
  7. In a monorepo, check which tsconfig the file belongs to — and remember paths in a shared base config resolve relative to that file, not the package extending it.

If the checklist did not settle it, here is each resolver in detail.

Next.js build: webpack and Turbopack read tsconfig natively

The good news first: for the actual build, there is nothing to install. The Next.js documentation states that Next.js "has in-built support for the \"paths\" and \"baseUrl\" options of tsconfig.json and jsconfig.json files" — that covers next dev, next build, webpack and Turbopack alike. create-next-app configures @/* for you by default.

So when next build prints Module not found: Can't resolve '@/...', the mapping the bundler loaded does not cover that specifier. The usual causes:

  • The dev server was started before you edited paths (checklist item 1).
  • The mapping target is wrong for your layout — ./src/* in a project without src/, or vice versa.
  • The alias prefix was renamed in tsconfig but not in the imports (or the other way round).
  • Case mismatch: @/components/button vs a file named Button.tsx builds on macOS and Windows but fails on Linux CI, because those filesystems are case-insensitive and CI's is not.

If the module genuinely exists and the alias is fine, you are likely looking at a different failure class entirely — see Next.js build module not found for the non-alias causes.

The TypeScript language server: ts(2307) in the editor

The IDE error Cannot find module ... ts(2307) comes from the TypeScript language server, which resolves against tsconfig.json on its own. Three things trip it:

Stale config. The TS server loads tsconfig when the workspace opens. It is better than it used to be at picking up edits, but a restart (TypeScript: Restart TS Server) is still the first move after any paths change.

The baseUrl question. Older answers insist baseUrl is mandatory for paths. That has been false since TypeScript 4.1, whose release notes say plainly: "In TypeScript 4.1, the paths option can be used without baseUrl." When baseUrl is absent, the current tsconfig reference documents that paths entries resolve "relative to the baseUrl if set, or to the tsconfig file itself otherwise". So both of these are valid, and mixing their conventions is the bug:

// Option A  no baseUrl (TS 4.1+): targets relative to this file
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

// Option B  with baseUrl: targets relative to baseUrl
{ "compilerOptions": { "baseUrl": "src/", "paths": { "@/*": ["./*"] } } }
Enter fullscreen mode Exit fullscreen mode

Files outside include. If a script in scripts/ or a test outside your include globs imports via @/, the TS server may associate it with a different (or inferred) config that has no paths at all. Widen include, or give that directory its own tsconfig that extends the root one.

Note that ts(2307) with a bare package name rather than an alias is a different problem — missing type declarations — covered in our fix for "could not find a declaration file for module".

Jest: it never reads tsconfig paths

This is the one that catches almost everyone. Jest has its own resolver and does not consult compilerOptions.paths. Types pass, the app builds, and then every test that touches an aliased import dies with Cannot find module '@/lib/utils'.

The fix is Jest's moduleNameMapper option, which the Jest docs describe as the mechanism to "stub out resources" and map "module paths to aliases" using regex patterns with captured groups:

// jest.config.js
module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}
Enter fullscreen mode Exit fullscreen mode

The ^ and $ anchors matter: an unanchored pattern can swallow unrelated package names (the Jest docs' own example is a bare relay pattern accidentally matching react-relay and graphql-relay). The mapping must mirror your tsconfig by hand — if you change one, change the other. In larger projects, ts-jest ships a pathsToModuleNameMapper helper that generates this object from tsconfig so the two cannot drift.

ESLint: import/no-unresolved needs a resolver

If the build and the types are green but ESLint underlines the import with import/no-unresolved, that is eslint-plugin-import resolving with its default Node resolver, which knows nothing about tsconfig aliases — precisely the situation in next.js discussion #19891. Point the plugin at a TypeScript-aware resolver:

npm i -D eslint-import-resolver-typescript
Enter fullscreen mode Exit fullscreen mode
// eslint config
settings: {
  'import/resolver': {
    typescript: { project: './tsconfig.json' },
  },
}
Enter fullscreen mode Exit fullscreen mode

Next.js's own eslint-config-next already wires up alias resolution, so you usually only hit this after adding eslint-plugin-import rules manually or bringing your own flat config.

Monorepo and extends gotchas

Two rules explain nearly every monorepo alias failure:

paths resolve relative to the file that declares them. If packages/web/tsconfig.json extends ../../tsconfig.base.json, and the base file declares "@/*": ["./src/*"], that target points at src/ next to the base config — the repo root — not at packages/web/src/. Per the tsconfig reference, resolution is relative to baseUrl if set, otherwise to the tsconfig file containing the paths. Inherited values are not re-anchored to the extending file. The robust pattern is to keep shared strictness options in the base and declare paths in each package's own tsconfig.

Each tool resolves for the package it runs in. The Next.js app reads the tsconfig in its directory; a Jest config at the repo root maps <rootDir> to the root. In a monorepo, <rootDir>/src/$1 in a shared Jest config points at the wrong src/ for every package but one — use per-package Jest configs or Jest projects.

A complete, commented tsconfig

{
  "compilerOptions": {
    // Optional since TS 4.1. If you omit it, every entry in
    // "paths" is resolved relative to THIS file — hence "./".
    "baseUrl": ".",

    "paths": {
      // "@/components/Button" -> "./src/components/Button"
      // No src/ directory? Use "./*" instead.
      "@/*": ["./src/*"]
    },

    // The usual Next.js options (target, lib, jsx, moduleResolution,
    // the "next" plugin, etc.) live here as generated by next dev.
    "strict": true,
    "isolatedModules": true
  },

  // Files matched here are checked WITH the paths above.
  // A file outside these globs will not see your alias.
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

And the two companion declarations that tsconfig cannot provide:

// jest.config.js — Jest ignores tsconfig paths entirely
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }

// eslint — only if import/no-unresolved misfires
settings: { 'import/resolver': { typescript: { project: './tsconfig.json' } } }
Enter fullscreen mode Exit fullscreen mode

The mental model to keep: paths is documentation for the type checker, and every other resolver — bundler, test runner, linter — either reads it natively (Next.js) or needs its own copy (Jest, sometimes ESLint). When an alias breaks, name the tool that produced the error and you have already found the config file to fix.

If aliases resolve but the types coming through them misbehave, the problem has moved one layer up — start with module has no exported member, brush up on interfaces vs types if declaration shapes are the issue, and the rest of our TypeScript troubleshooting lives in the TypeScript hub.


Originally published at https://www.iloveblogs.blog

Top comments (0)