Debugging Turbopack Module Resolution Errors in Next.js

You switched dev to next dev --turbopack and a build that worked under Webpack now throws Module not found: Can't resolve '@/lib/utils' or Can't resolve 'some-pkg' — this guide diagnoses why Turbopack’s resolver disagrees with Webpack and how to fix each class of failure. Resolution is one node type in the demand-driven graph described in Turbopack Incremental Compilation; when its inputs are wrong, every downstream transform fails, so fixing resolution first is non-negotiable.

Turbopack ships in Next.js and is enabled with next dev --turbopack (stable as of Next.js 15). Its resolver is a fresh Rust implementation, not a port of Webpack’s enhanced-resolve, so configuration that Webpack tolerated implicitly must be made explicit.

The reason this bites teams so consistently is that resolution failures are silent until the exact moment a module is first requested. Turbopack builds its graph lazily — it does not resolve every import in the project at startup, only the ones reachable from the route you actually load. So a misconfigured alias can sit dormant through next dev startup and only explode when you navigate to the page that imports it, which makes the failure feel intermittent even though it is deterministic. Under Webpack the same import often “worked” only because a permissive default extension list, an implicit main fallback, or a tsconfig-paths-webpack-plugin quietly patched over a gap that was never really specified. Flipping to Turbopack removes that safety net all at once.

It matters where in the pipeline this sits. Resolution is the first thing that happens to an import specifier: the resolver turns a string like @/lib/utils into an absolute path on disk, and only then does Turbopack hand that file to a transform node for parsing, JSX/TypeScript lowering, and module-graph linking. If resolution returns nothing, none of the downstream work can run, so a single unresolved specifier aborts the whole route rather than degrading gracefully. That ordering is why this guide fixes resolution before touching anything else — a transform error you chase for an hour is frequently a resolution error wearing a costume.

Turbopack module resolution decision path An import specifier is classified as alias, relative, or bare, then resolved through tsconfig paths, extensions, and package exports conditions before failing or succeeding. Resolve an import specifier import '@/lib/x' classify specifier Alias / tsconfig path resolveAlias, paths Relative path resolveExtensions Bare package exports conditions Resolved feed transform node Module not found TURBOPACK=1 trace
Figure: Turbopack classifies each specifier, then resolves through aliases, extensions, or package exports; a miss surfaces as "Module not found", best traced with TURBOPACK=1.

How Turbopack Resolution Differs from Webpack

Three differences cause most regressions when teams flip the --turbopack flag:

Three ways Turbopack's resolver is stricter than Webpack's Turbopack does no implicit extension guessing beyond the configured list, honors package exports and condition keys precisely rather than falling back to main, and does not inherit any Webpack resolve.alias so aliases must be restated under turbopack.resolveAlias. what Webpack tolerated implicitly, Turbopack requires explicitly extensionsonly resolveExtensionsno implicit guessingmissing .ts now fails exports mapprecise conditionsno main fallbackomitted subpath fails aliasesnot inheritedfrom webpack configrestate resolveAlias
Figure: the resolver is a fresh Rust implementation, not a port — every implicit convenience must be spelled out.
  • No implicit extension guessing beyond the configured list. Webpack’s defaults plus loose project config often masked an import missing its .ts/.tsx. Turbopack resolves only the extensions in resolveExtensions (defaults: .tsx .ts .jsx .js .mjs .cjs .json), in order.
  • Stricter package exports/conditions handling. Turbopack honors the exports map and condition keys (import, require, node, default) precisely. A package that “worked” in Webpack because it fell back to main may now resolve to nothing if its exports map omits the subpath you import.
  • Aliases are not inherited from a Webpack config. Any resolve.alias, webpack() mutation, or tsconfig-paths-webpack-plugin you relied on is invisible to Turbopack. You restate aliases under turbopack.resolveAlias, and tsconfig paths are read directly.

How resolution works under the hood

For each specifier, Turbopack runs the same fixed sequence. First it classifies the string: a specifier beginning with . or .. is relative, one that matches a resolveAlias key or a tsconfig paths pattern is an alias, and anything else is a bare package name. Alias and path patterns are matched longest-prefix-first, and a trailing /* in the key captures the remainder to substitute into the target — so @/* mapping to ./src/* rewrites @/lib/utils to ./src/lib/utils before the rest of the pipeline sees it. Aliases are expanded first, which means an alias target can itself be relative or bare and then goes back through the same classifier.

Once a candidate path exists, Turbopack applies resolveExtensions in order and stops at the first file that exists on disk. This is a strict left-to-right walk, not a “best match” — if both utils.js and utils.ts exist and .tsx/.ts come first in the list, the TypeScript file wins, and reordering the list silently changes which file compiles. For a directory target it then looks for an index file using the same extension list. For bare packages the resolver reads the package’s package.json, and if an exports field is present it is authoritative: the resolver walks the condition keys in resolveConditions order and never falls through to main or module for a subpath the map does not declare. Only packages with no exports field fall back to the legacy main/module/browser fields. Every path the resolver touches — including the ones that did not exist — is recorded as a dependency of that resolution, which is how a later git checkout that creates the missing file invalidates the cached “not found” result and triggers a rebuild.

Prerequisites & Reproducible Setup

# Next.js 15.3.x / Node 20+
npx create-next-app@latest tp-resolve-demo --ts --app --no-tailwind
cd tp-resolve-demo
npm pkg set scripts.dev="next dev --turbopack"
mkdir -p src/lib && echo "export const ping = () => 'pong';" > src/lib/utils.ts

Now import it from a page with import { ping } from '@/lib/utils'. If the @/* path is not configured for Turbopack, npm run dev throws Module not found: Can't resolve '@/lib/utils'.

The reason a freshly scaffolded create-next-app project does not throw here is that the generated tsconfig.json already ships a paths entry for @/*, and Turbopack reads that file directly. The failure in this section is what you get once someone edits baseUrl, moves the config, or adds an alias that lives only in a Webpack block — the tsconfig no longer describes reality and Turbopack has nothing else to consult. Reproducing the break deliberately is worth the two minutes: it lets you confirm that a given fix actually changed the resolver’s behavior rather than being masked by editor tooling that resolves the import through a completely separate TypeScript language-server path.

A reproducible alias failure in four steps Scaffold the app, set the turbopack dev script, create src/lib/utils.ts, then import it with the @/lib/utils alias; with no matching resolveAlias the dev server throws Module not found, which is the failure this guide fixes. scaffold app--ts --app create utils.tssrc/lib/ import @/lib/utilsalias specifier Module not foundno resolveAlias
Figure: the error at the end of this chain is exactly the one the config in the next section resolves.

Diagnosis Workflow

The failing specifier's shape points at the fix Read the specifier first: an @-prefixed alias points at a tsconfig paths or resolveAlias problem, a bare package name points at an exports map or install problem, and a relative path points at a missing extension or a typo; a resolver trace confirms which. read specifierclassify shape @/… aliaspaths / resolveAlias bare packageexports / install relative pathextension / typo
Figure: the specifier's shape narrows the fix before you touch a single config file; the TURBOPACK=1 trace confirms it.
  1. Read the full error. Turbopack prints the failing specifier and the importing file. A @/-prefixed specifier points at an alias/paths problem; a bare name points at exports/install; a relative path points at extensions or a typo. This first read is not a formality — it decides which of the three fixes below you even attempt. The most common time-sink is treating an alias failure as a missing-dependency failure and reinstalling packages that were never the problem, so anchor everything on the shape of the specifier printed in the error, not on a guess about what “should” be installed.
  2. Check tsconfig paths are mirrored. Turbopack reads compilerOptions.paths from tsconfig.json, but a custom baseUrl or a non-standard config location can break this; confirm the mapping resolves to a real file on disk. Resolve the mapping by hand: take the @/* target, substitute the captured segment, and ls the resulting path. If ls ./src/lib/utils.ts finds the file but the dev server still fails, the tsconfig Turbopack loaded is not the one you edited — a nested tsconfig.json, an extends chain, or a monorepo package with its own config will each shadow the root. The symptom that most reliably fingers this is an editor that resolves the import cleanly while the terminal does not, because the language server and Turbopack picked up different config files.
  3. List what the package actually exports. For a bare-import failure run cat node_modules/<pkg>/package.json | grep -A20 '"exports"' and confirm the subpath you import is declared. Read the exports object as a literal contract: if you import pkg/internal and the map has no "./internal" key (or a wildcard "./*" that would cover it), the resolver is correct to refuse and no amount of Turbopack config will change that. Note the condition keys too — a subpath whose value is { "require": "./cjs/internal.js" } with no "import" key cannot be resolved when your resolveConditions list is ESM-first, and that mismatch reads as “not found” even though the file exists.
  4. Trace the resolver. Set TURBOPACK=1 for verbose internals and capture the attempted candidate paths. The trace is the ground truth: it lists every candidate the resolver tried and the extension order it walked, so you can see the exact point at which a lookup fell off the end of the list. Pipe it to a file rather than reading it live, because a single navigation can emit thousands of resolution events and the one you care about scrolls past instantly.
# Verbose Turbopack internals (Next.js 15.3.x)
TURBOPACK=1 NEXT_TURBOPACK_TRACING=1 next dev --turbopack 2>&1 | tee resolve-trace.log
grep -i "resolve" resolve-trace.log
  1. Check for symlinks in a monorepo: ls -l node_modules/<pkg> — a symlink into a workspace package that is not declared as a dependency will not be tracked. The distinction that matters is between a symlink the resolver follows and one it watches: Turbopack will happily resolve through a symlink that resolution reaches, but it only adds a path to its watch set when that path is a declared dependency of the importing package. So a workspace import can resolve on the first build and then never rebuild when the linked source changes, which is the same class of staleness discussed under incremental compilation. If ls -l shows the arrow (->) into a sibling packages/ directory, cross-check that the sibling appears in the importing package’s dependencies before assuming the resolver is at fault.

Solution Configuration

This next.config.js and matching tsconfig.json make every resolution input explicit. Both are complete and runnable.

Three resolveAlias, extensions and conditions keys plus mirrored tsconfig The fix sets resolveAlias to restate aliases and pin a single React copy, resolveExtensions for the tried order, and resolveConditions to pick browser-first exports; the tsconfig paths mapping must stay identical to resolveAlias or the editor and dev server disagree. make every resolution input explicit resolveAlias@/* + single React resolveExtensions.tsx .ts .jsx … resolveConditionsbrowser, import first tsconfig paths must equal resolveAlias — drift = editor ≠ dev server
Figure: the config's three keys plus a matching tsconfig — the highlighted invariant is that the two @/* mappings never drift.
// next.config.js — Next.js 15.3.x / Node 20+
/** @type {import('next').NextConfig} */
const nextConfig = {
  turbopack: {
    // Restate aliases — Turbopack does NOT read a Webpack resolve.alias.
    // Keys are exact specifiers or end in /* to mirror tsconfig paths.
    resolveAlias: {
      '@/*': ['./src/*'],
      // Force a single React copy across symlinked monorepo packages.
      react: './node_modules/react',
      'react-dom': './node_modules/react-dom',
    },
    // Extensions are tried in this order. Add custom ones you import without an extension.
    resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
    // Pick condition keys for packages with an exports map (browser-first here).
    resolveConditions: ['browser', 'import', 'require', 'default'],
  },
};

module.exports = nextConfig;
// tsconfig.json — read directly by Turbopack for paths
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "moduleResolution": "bundler"
  }
}

Each key earns its place. The two react/react-dom aliases pin a single physical copy of React so that a symlinked component library cannot drag in its own nested copy; two React instances resolve fine but crash at runtime with the “invalid hook call” error, which is a resolution problem that never prints “Module not found”. resolveExtensions is stated explicitly even though it matches the default, because the order is load-bearing and a future edit that appends a custom extension should start from a list you can see rather than an implicit one. resolveConditions is where you encode the ESM-first preference; browser before node keeps a package’s browser build from being swapped for its server build in the client graph.

When tsconfig.json paths and turbopack.resolveAlias both define @/*, keep them identical. Drift between the two is a common cause of an editor that resolves the import while the dev server does not. Prefer to make tsconfig paths the single source of truth wherever you can — Turbopack reads it directly, so a plain path alias like @/* usually needs no resolveAlias entry at all. Reserve resolveAlias for the cases tsconfig cannot express: pinning a single package copy, swapping one package for another, or aliasing a bare specifier to a file. When both must exist for the same key, treat the tsconfig entry as canonical and copy from it, never the reverse.

Worked example: pinning a package in a monorepo

Consider a workspace where an internal @acme/ui package is symlinked in and pulls its own react from its nested node_modules. The app renders, then throws “invalid hook call” the moment a @acme/ui component uses state. The fix is not in the component — it is to force every import of react and react-dom, from any package, to resolve to the app’s single copy:

// next.config.js — Next.js 15.3.x / Node 20+  (monorepo React pinning)
const path = require('node:path');

/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['@acme/ui'], // let Turbopack compile the workspace source
  turbopack: {
    resolveAlias: {
      // Absolute targets beat any nested node_modules copy.
      react: path.resolve(__dirname, 'node_modules/react'),
      'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
    },
  },
};

module.exports = nextConfig;

Confirm the pin took effect by tracing a @acme/ui import and checking that react resolves to the app-level node_modules/react, not packages/ui/node_modules/react. If the nested copy still wins, the alias target is wrong — an absolute path built with path.resolve is more reliable here than the ./node_modules/react relative form, because relative alias targets are resolved from the project root and a typo fails open rather than erroring.

Verification

A full restart is mandatory because config is not hot-reloaded Turbopack does not hot-reload next.config.js, so after editing it you must remove .next and restart; a clean start compiles the route with no Module not found line, and re-running the trace shows the import resolving to a real node_modules path. config is not hot-reloaded — a stale process keeps throwing the old error rm -rf .nextdiscard stale state ✓ Compiled in Nmsno Module not found trace resolvesnode_modules/…/dist
Figure: skip the restart and you debug a fix that is already applied — the stale process is the trap here.

After saving the config, restart the dev server — Turbopack does not hot-reload next.config.js, so a stale process keeps throwing the old error. A clean start prints the route compiling without a Module not found line, and the page renders the imported value:

rm -rf .next && next dev --turbopack
# expect: "✓ Compiled / in <N>ms" and no "Module not found" in output

For a bare-package failure, confirm resolution lands on a real file by re-running the trace and grepping for the resolved absolute path:

TURBOPACK=1 next dev --turbopack 2>&1 | grep -i "<pkg-name>"
# expect a node_modules/<pkg>/dist/... path, not a "failed to resolve" line

Gotchas & Edge Cases

Four resolution edge cases An exports map omitting a subpath cannot be forced from config; a wrong condition order resolves to CJS and surfaces as a runtime is-not-a-function error; a monorepo symlink not declared as a dependency is not tracked; and case-sensitivity means an import that works on macOS fails in Linux CI. exports omits subpath→ import a declared entry / patch wrong condition → CJS→ import before require symlink not tracked→ declare workspace dependency case-sensitivity→ macOS ok, Linux CI fails
Figure: the wrong-condition case is the sneaky one — it resolves fine but fails at runtime, not at build.

exports map omits the subpath. import x from 'pkg/internal' fails when pkg’s exports does not declare ./internal. You cannot force it from Turbopack config — either import a declared entry point or patch the package’s exports via your package manager’s override/patch mechanism.

Wrong condition resolved. A package that ships both ESM and CJS can resolve to the CJS branch if your resolveConditions order is wrong, surfacing as a runtime is not a function rather than a resolution error. Put import before require for ESM-first packages.

Monorepo symlink not tracked. A workspace package symlinked into node_modules but absent from the importing package’s dependencies/devDependencies will not be resolved or watched. Declare it as a workspace dependency. These same symlink gaps cause stale rebuilds, covered in Turbopack Incremental Compilation.

Case-sensitivity. Importing @/Lib/utils resolves on macOS and fails in Linux CI. Turbopack is case-sensitive on case-sensitive filesystems; match the on-disk casing exactly. The trap is that macOS and Windows default filesystems are case-insensitive, so the import works on every developer’s laptop and fails only in CI or production on Linux, where the mismatched casing finally counts. Fix it at the import site, not by renaming the file, since the file’s real casing is what your teammates already reference.

CI integration

Resolution errors that hide behind case-insensitive local filesystems or an editor’s separate language server are exactly the ones a build step should catch before merge. The cheapest guard is a real production build in CI on Linux, which forces every reachable specifier through the resolver rather than only the routes a developer happened to open:

# CI gate — Next.js 15.3.x / Node 20+ on Linux
set -e
npm ci
next build --turbopack   # fails the job on any unresolved specifier

Because the production build resolves the full graph, an alias or extension gap that next dev deferred until a page load surfaces here as a non-zero exit code. If you run the dev server in CI for end-to-end tests, add TURBOPACK=1 NEXT_TURBOPACK_TRACING=1 and archive the trace as a build artifact — when a resolution failure only reproduces on the CI runner, that captured trace is usually faster to read than trying to reproduce the environment locally.