The Error
You run npm run start:dev, TypeScript compiles cleanly — zero errors — and then Node crashes:
[9:13:16 PM] Starting compilation in watch mode...
[9:13:19 PM] Found 0 errors. Watching for file changes.
Error: Cannot find module '/path/to/backend/dist/main'
at Module._resolveFilename (node:internal/modules/cjs/loader:1212:15)
Zero TypeScript errors. Yet the server won't start. What's going on?
Background: How NestJS CLI v11 Starts Your App
In older versions of the NestJS CLI, nest start --watch used ts-node under the hood. ts-node compiles TypeScript on-the-fly in memory — it never writes .js files to disk. The dist/ folder was irrelevant for development.
@swc/core installed, it now defaults to a tsc watch + node pipeline:
1. Run tsc --watch → compile TypeScript and write .js files to dist/
2. When compilation finishes, run node dist/main.js
3. On file changes, recompile and restart
This means dist/main.js must exist at exactly the right path. If it doesn't — even if there are zero TypeScript errors — the process crashes.
The Root Cause: TypeScript's rootDir Inference
TypeScript has a compiler option called rootDir. It controls the output directory structure — specifically, it defines the "root" from which all input file paths are computed when writing to outDir.
You almost never set rootDir explicitly. TypeScript infers it automatically from your include array:
TypeScript sets rootDir to the longest common ancestor path of all input files.
This is the key insight. With a typical backend-only setup:
{
"compilerOptions": {
"outDir": "./dist"
},
"include": ["src"]
}
All input files are under backend/src/. The common ancestor is src/. So:
src/main.ts → dist/main.js ✓
src/app.module.ts → dist/app.module.js ✓
NestJS CLI looks for dist/main.js — and it's there.
What Happens When You Add a Directory Outside the Project
Now say you add a shared module directory — maybe some types or utilities you want to share between the backend and another service. You add it to include:
{
"include": ["src", "../shared"]
}
TypeScript now sees input files in two locations:
backend/src/*.tsshared/*.ts(one level up)
The longest common ancestor of backend/src/ and shared/ is the parent directory — the monorepo root.
So rootDir silently expands to ../ (the parent), and the output becomes:
backend/src/main.ts → dist/backend/src/main.js ✗
shared/contract.ts → dist/shared/contract.js
NestJS CLI still tries to run node dist/main.js. That file doesn't exist. The app crashes — with zero TypeScript errors, because the TypeScript part succeeded perfectly.
Why This Is So Confusing
Three things conspire to make this maddening to debug:
1. TypeScript reports zero errors. The compilation genuinely succeeds. The output structure is wrong, not the TypeScript. 2. The error says "module not found" — which looks like a runtime import problem, not a build configuration issue. You'll waste time checking your module names. 3. The previous behavior was different. If you were using ts-node before (older NestJS CLI), this never surfaced. Thedist/ folder was never used. Everything worked. You only discover the breakage when you upgrade or when the CLI starts actually using the compiled output.
The Solution: TypeScript Project References
The right fix is TypeScript Project References. This is TypeScript's official solution for monorepo-style setups where multiple packages need to share code.
How Project References Work
Instead of pulling shared source files into your compilation (which expands rootDir), you tell TypeScript:
"That directory is a separate TypeScript project. Compile it independently, and use its compiled .d.ts output when type-checking my code."
TypeScript then uses the pre-compiled declaration files for type information and never adds the shared .ts files to the current project's input file list. rootDir is not affected.
Step 1: Create a tsconfig.json for the Shared Module
In your shared directory, add a tsconfig.json with composite: true:
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "ES2023",
"outDir": ".",
"rootDir": ".",
"composite": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["."],
"exclude": ["<em></em>/*.js"]
}
Key settings:
composite: true— required for project references to work. It enforces that the project always emits.d.tsfiles and enables incremental rebuilds.declaration: true— emits.d.tstype declaration files alongside the compiled.js.outDir: "."androotDir: "."— compile the.jsand.d.tsfiles in-place, alongside the TypeScript source. This keeps the compiled runtime files at a predictable, stable location.
Step 2: Add References to the Backend tsconfig.json
In both tsconfig.json and tsconfig.build.json:
{
"compilerOptions": { ... },
"include": ["src"],
"references": [{ "path": "../shared" }]
}
Two important changes:
- Remove
"../shared"frominclude— this is what caused the problem. - Add the
referencesarray — this tells TypeScript to use the shared project's compiled output instead of its source.
⚠️referencesis not inherited viaextends. You must add it to bothtsconfig.jsonandtsconfig.build.jsonseparately.
Step 3: Exclude prisma/seed.ts from the Build Config
You may have other directories in your include that contain .ts files outside src/. A common example is a prisma/ directory with a seed.ts file. This file widens rootDir from src/ to backend/, shifting the output from dist/main.js to dist/src/main.js — still wrong.
Add it to exclude in tsconfig.build.json:
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "<em></em>/*spec.ts", "prisma"]
}
The seed file is typically run with ts-node directly, not compiled by the main build. Excluding it from the build tsconfig is correct.
Step 4: Handle Runtime Alias Resolution
TypeScript paths aliases (like @contract, @engine) are a compile-time type-checking feature only. The TypeScript compiler does not rewrite require('@contract') to a relative path in the compiled output. After tsc runs, your .js files still contain require('@contract'), which Node.js cannot resolve on its own.
tsconfig-paths is the standard solution. It reads your tsconfig.json at runtime and registers a custom module resolver that handles the aliases.
Add it as the very first import in main.ts:
import 'tsconfig-paths/register';
import { NestFactory } from '@nestjs/core';
// ... rest of imports
It must be first — before any other import that might trigger alias resolution.
For this to work at runtime, the shared module's .js files must actually exist on disk — which is exactly what the in-place compilation in Step 1 provides.
Step 5: Add Pre-Build and Pre-Start Scripts
The shared files must be compiled before the backend starts or builds. Add lifecycle scripts to package.json:
{
"scripts": {
"prebuild": "tsc -p ../shared/tsconfig.json",
"build": "prisma generate && nest build",
"postbuild": "tsc-alias -p tsconfig.build.json",
"prestart": "tsc -p ../shared/tsconfig.json",
"start": "nest start",
"prestart:dev": "tsc -p ../shared/tsconfig.json",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
}
}
npm automatically runs pre* scripts before their corresponding commands. So npm run start:dev will compile the shared module first, then start the NestJS watcher.
Also note: start:prod is now node dist/main (not dist/src/main), reflecting the corrected output path.
Step 6: Gitignore the Compiled Shared Artifacts
The .js and .d.ts files in the shared directory are generated artifacts. Add a .gitignore to the shared directory:
*.js
*.d.ts
*.d.ts.map
tsconfig.tsbuildinfo
This keeps the repository clean. The files are regenerated by the prebuild/prestart scripts on every developer machine and in CI.
The Full Picture
Here is how everything fits together after the fix:
npm run start:dev
│
├─ prestart:dev: tsc -p ../shared/tsconfig.json
│ └─ Compiles shared/contract.ts → shared/contract.js + shared/contract.d.ts
│
└─ nest start --watch
│
├─ tsc --watch (uses tsconfig.build.json)
│ ├─ Sees "references: ../shared" → uses shared/contract.d.ts for types
│ ├─ rootDir = src/ (only src/ files in the compilation)
│ └─ Outputs: dist/main.js ✓
│
└─ node dist/main.js
├─ require('tsconfig-paths/register') ← first line of main.ts
│ └─ registers @contract → ../shared/contract.js resolver
└─ rest of app boots normally ✓
Summary of Changes
| File | Change | Why |
|---|---|---|
| shared/tsconfig.json | Created with composite: true | Enables project references |
| shared/.gitignore | Created | Excludes compiled artifacts from git |
| backend/tsconfig.json | Removed ../shared from include, added references | Prevents rootDir expansion |
| backend/tsconfig.build.json | Added prisma to exclude, added references | Prevents seed.ts from widening rootDir |
| backend/src/main.ts | Added import 'tsconfig-paths/register' as first line | Runtime alias resolution |
| backend/package.json | Added pre* scripts, fixed start:prod path | Compile shared before start/build |
Key Takeaways
- TypeScript's
rootDiris inferred automatically from the common ancestor of all input files. Adding a directory outside your project toincludesilently shifts every output path. - NestJS CLI v11 uses tsc + node (not ts-node) for watch mode when SWC is not installed. The
dist/output path now matters for development, not just production. - TypeScript
pathsaliases are type-checking only. Runtime resolution requires a separate mechanism —tsconfig-paths/registeris the most common solution. - Project References are the right tool for sharing TypeScript code between packages in a monorepo. They give you type safety across packages without merging the compilations.
referencesis not inherited viaextends. You must add it explicitly to every tsconfig that needs it.