monorepo/
├── tsconfig.base.json
├── apps/
│ ├── backend/tsconfig.json
│ └── frontend/tsconfig.json
└── packages/
└── shared/tsconfig.json
Only universal options — no paths or includes:
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"target": "ES2021"
}
}{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2021"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"incremental": true
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src"],
"exclude": ["node_modules", "dist"]
}{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"noEmit": true
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src"],
"exclude": ["node_modules"]
}{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2021"],
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}With nodenext, TypeScript reads the module format from the nearest package.json rather than a fixed tsconfig value. For the backend and shared package, simply omit "type": "module" from their package.json and they default to CommonJS.
Shared package's package.json:
{
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}Consuming apps declare a references array in their tsconfig.json. Running tsc --build then compiles shared first, followed by the apps.
Because shared now resolves to dist/ instead of source, both the frontend (Vite) and the backend dev process need shared/dist to exist. So your dev flow must build shared first — ideally in watch mode. For example, run tsc -b packages/shared --watch alongside the app, or use a pnpm/turbo script that builds shared before vite dev.
Same idea as the backend config above, minus the NestJS-specific bits. As of Node 24 LTS, Node strips TypeScript types and runs .ts files natively, so the dev loop needs zero extra tooling — no ts-node, no nodemon, no tsx.
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2021"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"erasableSyntaxOnly": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}If this lives in its own repo instead of the monorepo, drop the
extendsline and inline the options from the roottsconfig.base.jsonabove.
{
"scripts": {
"dev": "node --watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}| Option | NestJS | Plain Express | Why |
|---|---|---|---|
emitDecoratorMetadata |
✅ | ❌ removed | Only Nest's DI needs runtime reflection |
experimentalDecorators |
✅ | ❌ removed | Same — no decorators in plain Express |
erasableSyntaxOnly |
— | ✅ added | Keeps dev (native strip) and build (tsc) in sync |
Everything else — module: nodenext, moduleResolution: nodenext, lib: ["ES2021"], types: ["node"], outDir, rootDir, incremental — carries over unchanged.