</>CodeWithKarani

Cannot find module '@/...': why path aliases break across transpilePackages

Karani GeoffreyKarani Geoffrey8 min read

You split a shared component out into its own workspace package, wired it into your Next.js app with transpilePackages, and the editor immediately lit up red on the import. The package builds fine on its own. Every path inside it resolves. But the moment the app pulls it in, TypeScript insists the module does not exist.

This looks like a broken tsconfig. It is not. It is a structural fact about what a path alias actually is, and no amount of copying baseUrl and paths around will fix it properly until you understand that fact. A tsconfig alias is a private note the compiler leaves for itself, and it does not survive the trip across a package boundary.

paths and baseUrl are resolved at compile time by the compiler of the package that owns them. They are never rewritten into the emitted .js or .d.ts. When another package imports yours through transpilePackages or a workspace link, its compiler reads your files but not your tsconfig, so your @/ specifiers resolve against the wrong root and fail.

  • Do not fix this by pasting the imported package's paths into the consumer's tsconfig. It scales to zero other consumers and does nothing for the runtime.
  • Give the shared package a real name and an exports map in its package.json, and import it by that name.
  • Inside a package, prefer relative imports or subpath exports over a global @/ alias if that package will ever be consumed by another.
  • Under moduleResolution: "nodenext" the same alias produces the same error for the same reason. The fix is identical.

The exact error: "Cannot find module '@/overstyler' or its corresponding type declarations"

It shows up in the editor and in tsc --noEmit, pointing at an import inside the package you just linked in:

Cannot find module '@/overstyler' or its corresponding type declarations.

The tell is that the failing specifier belongs to the imported package, not to the file you are editing. Your own @/ imports still resolve. Only the ones that came in with the transpiled dependency are red. If you open the built .d.ts for that dependency you will see the alias sitting there untouched:

// packages/ui/dist/button.d.ts
import { Overstyler } from '@/overstyler';
export declare function Button(props: { styler: Overstyler }): JSX.Element;

That '@/overstyler' is the whole problem in one line. It was meant to mean "the overstyler module at the root of the ui package". By the time the app reads this file, there is no compiler in the room that knows that.

Why this happens: paths are compile-time-only, and they do not travel

The mental model to unlearn is that paths is a bundler feature or a runtime feature. It is neither. It is a hint to the TypeScript type checker, and only the type checker, telling it how to find the types behind a bare specifier during one compilation.

Three separate systems resolve modules in a Next.js monorepo, and only the first one has ever heard of your alias:

1. TypeScript type checker (tsc, the editor) Reads tsconfig baseUrl and paths. This is the ONLY place @/ means anything. It resolves aliases for type-checking, then emits code with the specifier unchanged. 2. The bundler (Turbopack / webpack in Next.js) Resolves real files and package.json fields. Ignores tsconfig paths unless a plugin re-adds them. 3. Node.js at runtime Resolves node_modules and the exports map. Has never read a tsconfig in its life. A specifier only survives all three if it is a real relative path or a real package export.
An alias that only system 1 understands is a specifier the other two will reject. Shipping code full of them is shipping a dependency on a compiler that is not there.

Now walk the failure. Package ui compiles itself. Its own tsconfig.json says baseUrl: "src" and paths: { "@/*": ["*"] }. So when tsc checks button.tsx, it resolves @/overstyler to src/overstyler.ts, type-checks happily, and emits button.js and button.d.ts with the literal string '@/overstyler' still in the import. TypeScript deliberately does not rewrite specifiers. That has always been its position: it is a type checker, not a bundler, and rewriting your imports is the bundler's job.

Then the app imports ui. Next.js sees it in transpilePackages and hands the package's source to its own compilation, which uses the app's tsconfig. The app's @/* points at the app's own src. There is no overstyler there. So the checker reports the module missing, and it is telling the truth: relative to the only tsconfig it is allowed to consult, that module does not exist.

This is why the error feels like a lie. The file is right there on disk. But "on disk" is not how alias resolution works. The alias needs a specific tsconfig to give it meaning, and that tsconfig does not cross the package boundary with the code.

The fix, in steps

Step 1: Stop the alias from leaking out of the package

The durable rule is simple. A @/ alias is fine for an app, which is the last stop and owns its own compilation. It is a liability in a library package that other code imports, because every one of those specifiers is a promise the consumer cannot keep. Inside a consumable package, use relative imports:

-import { Overstyler } from '@/overstyler';
+import { Overstyler } from './overstyler';

Relative specifiers survive all three resolution systems because they describe a real location on disk, not a mapping. This one change makes the emitted .d.ts portable. If the package is large and you truly want internal aliases, use the imports field in its package.json (subpath imports, the #internal/* form), which Node and bundlers both honour, rather than tsconfig paths, which only tsc honours.

Step 2: Give the package a real name and export surface

Consumers should import your package by its published name, not reach into its source through an alias. In packages/ui/package.json:

{
  "name": "@acme/ui",
  "version": "0.0.0",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./button": {
      "types": "./dist/button.d.ts",
      "default": "./dist/button.js"
    }
  }
}

Now the app writes import { Button } from '@acme/ui/button'. That specifier is a real package subpath. The type checker resolves it through the exports map, the bundler resolves it the same way, and Node resolves it at runtime. All three agree, which is the entire goal.

Step 3: If you keep the package as raw source, make the roots line up with project references

If you deliberately do not build the package and let transpilePackages compile its TypeScript source in-app, then the two packages must share a resolution story. Use TypeScript project references with a shared base tsconfig, and put the alias only in that shared base so both packages resolve @/ identically. Practically, this means the alias must map to a location valid from both packages, which usually means dropping the per-package @/ -> src convenience in favour of a workspace-rooted alias like @acme/ui/*. That is more typing and less magic, which is the correct trade for code that crosses a boundary.

Step 4: Keep the runtime honest, not just the editor

Even after the types resolve, remember the bundler. Next.js with transpilePackages will not apply your tsconfig paths to a dependency. If any alias is still present in code that actually runs, it must be handled by a resolver the bundler reads, which for the app itself is compilerOptions.paths that Next.js loads automatically, and for a dependency is nothing at all. So an alias in a dependency is not merely a type error, it is a runtime Module not found waiting for the moment the type error is suppressed. Fixing the specifier fixes both at once.

Verification

Prove the package is portable before you trust it. From the consumer app:

  1. Run tsc --noEmit in the app. Expect zero Cannot find module errors originating in the dependency's files.
  2. Grep the shipped types for surviving aliases: grep -rn "from '@/" node_modules/@acme/ui/dist. Expect no matches. A match means an alias leaked into the build.
  3. Do a production build with next build. A leaked alias that the type check missed will surface here as Module not found: Can't resolve '@/overstyler', which is the runtime resolver telling you the same thing the checker did.
  4. Delete the app's .next cache and rebuild once, so you are not reading a stale trace. If it is clean twice, the boundary is sound.

What people get wrong

Copying the dependency's paths into the app's tsconfig. This is the top-voted answer everywhere and it is a trap. It quiets the editor because now the app's checker happens to resolve @/overstyler too, but you have created an alias that means "the ui package's root" while sitting in an app whose own @/ means "the app's root". Add a second aliased dependency and the two definitions collide. Add a third consumer and it has to learn the incantation as well. You have turned a package boundary into a shared secret.

Reaching for tsconfig-paths or a webpack alias to force it at runtime. You can make the bundler honour paths with a plugin, and now the dependency runs. You have also coupled every consumer's build config to the internal layout of a package they should treat as a black box. The day the package renames src, every consumer breaks. Real package exports do not have this problem.

Assuming transpilePackages reads tsconfig. It transpiles the package's TypeScript so the app does not need a pre-built dist. It does not adopt the package's tsconfig paths. The two are unrelated, and expecting the first to imply the second is where most of these threads go in circles.

When it is still broken

The alias is gone but you still get "Cannot find module" on nodenext. Under moduleResolution: "nodenext" with "type": "module", relative imports need explicit file extensions in the specifier (./overstyler.js, referring to the emitted file even from a .ts source). A missing extension reads to the resolver as a missing module. Add the .js extension to relative imports and regenerate the types.

The types resolve but the shape is wrong or stale. Delete the package's dist and its .tsbuildinfo, then rebuild. Incremental builds happily serve a .d.ts from before you fixed the specifiers.

It only breaks in CI, not locally. Your local node_modules has a symlink to the package source that resolves aliases by luck of the filesystem layout, while CI installed a packed tarball that does not. Test with npm pack and install the tarball into a scratch app to reproduce what CI sees. If that is where things go wrong, the same discipline that makes deploys reproducible applies here; I wrote about a neighbouring version of this in ERR_REQUIRE_ESM on Vercel but not locally.

The one sentence to keep: an alias is a compiler's private convenience, and a package boundary is exactly the place where one compiler's conveniences stop being anyone else's. Ship real specifiers across it and the whole class of error disappears.

Frequently asked questions

Why does my @/ alias work when I build the package alone but break when another app imports it?
Because a tsconfig paths alias is resolved by the TypeScript compiler using the tsconfig of the package being compiled. When app B imports package A, B's compiler resolves A's files against B's tsconfig, which has no entry for A's @/ alias. The alias only ever existed at A's compile time; the shipped code and types still contain the unresolved '@/...' specifier.
Can I just add the imported package's paths to my own tsconfig?
You can, and it will silence the editor error, but it is fragile: the alias now means two different things in two packages, and any third consumer has to repeat the mapping. It also does nothing for the runtime, because bundlers and Node resolve modules without reading your tsconfig. Prefer shipping the package with relative or properly exported specifiers instead.
What is the correct way to share code between packages with aliases in a monorepo?
Give the shared package a real package name and an exports map in its package.json (for example '@acme/ui' with an 'exports' field), and import it by that name from other packages. Inside the package you may still use a local alias, but it must be resolved away before the package is consumed, either by a build step or by using subpath exports rather than tsconfig paths.
I get the same error with moduleResolution nodenext. Is it the same problem?
It is the same class of problem. Under 'nodenext' with type:'module', TypeScript enforces that a specifier must resolve the way Node resolves it at runtime, and tsconfig paths are not part of Node resolution. So an alias that tsc tolerated under 'bundler' resolution now errors. The fix is the same: use a real package name and an exports map, or relative paths with explicit extensions.
#TypeScript#Next.js#Monorepo#tsconfig#Module Resolution
Keep reading

Related articles