NestJS 12 Migration Guide: Upgrading from v11, Tested and Benchmarked
I upgraded a real NestJS 11 API to NestJS 12.1.3 with nest upgrade, fixed what broke (TypeScript 6, Jest and ESM, @Optional), and benchmarked v11 vs v12, Zod vs class-validator, Jest vs Vitest, ESLint vs oxlint, TypeScript 7 and Bun.

On this page
NestJS 12 is the first major release of the Node.js framework since v11. It was released on August 27, 2026, and 12.1.3 is the current version. The headline changes are ESM-only packages, built-in Standard Schema validation (so Zod, Valibot and ArkType work without a custom pipe), a rebuilt CLI with a nest upgrade command, Vitest and oxlint as defaults for new projects, and the new @nestjs/observe SDK. Existing CommonJS apps keep working.
The release notes cover what changed. This guide covers what happened when I actually upgraded. I built a small but realistic NestJS 11 API with class-validator DTOs, Joi config validation, lifecycle hooks, an @Optional() dependency and e2e tests. I ran nest upgrade on it, fixed everything that broke, and benchmarked v11 against v12 for startup, throughput and memory. I also measured Zod against class-validator, Jest against Vitest, ESLint against oxlint, TypeScript 6 against TypeScript 7, and NestJS 12 on Bun. Every number below comes from those runs.
TL;DR: should I upgrade to NestJS 12?#
Yes, but budget an hour rather than a minute. nest upgrade bumped every package and rewrote my config module correctly, but the build and the test suite both failed afterwards. TypeScript 6 needs three tsconfig fixes, and Jest needs --experimental-vm-modules to load the ESM-only packages. Once those are fixed, request throughput is the same as v11 (within 5%), cold start is about 90 ms slower, and you can move to Zod validation, which was 28% faster than class-validator in my test.
What's new in NestJS 12?#
The NestJS v12.0.0 release groups the changes into five areas:
- ESM packages. Every core
@nestjs/*package now ships as ESM. A CommonJS app loads them through Node'srequire(esm), so you don't have to convert your own code. - Standard Schema.
@Body(),@Query(),@Param()and@RawBody()take aschemaoption. The newStandardSchemaValidationPipeandStandardSchemaSerializerInterceptorvalidate requests and responses with any Standard Schema library.@nestjs/confignow accepts a Zod schema asvalidationSchematoo. - A rebuilt CLI. It adds
nest upgrade(with--dry-run),nest deploy, Rspack as the default bundler for monorepos, andbunas a package manager.nest newscaffolds ESM, Vitest and oxlint by default. - Native observability.
@nestjs/observehooks into Nest's own request lifecycle instead of patching the HTTP server like a generic APM agent. - Smaller additions. These include
routeConflictPolicyandrouteResolutionStrategyfor route shadowing, machine-readableerrorCodeon HTTP exceptions, structured logging params, and graceful shutdown in the Express adapter.
The breaking changes that matter most for typical REST APIs are listed in the official migration guide: ESM packages, lifecycle hook ordering by component hierarchy, @Optional() inheritance, @nestjs/config moving to Standard Schema (Joi must be v18+), NATS v3, graphql-ws-only subscriptions, and the removal of the legacy Terminus health indicator API.
How I tested the NestJS 12 upgrade#
- App: a NestJS 11.2.7 project scaffolded with CLI 11.0.24 (Express adapter), plus a
usersmodule. It has a class-validator DTO,@nestjs/config4 with a Joi 17 schema and customvalidationOptions,onModuleInitandonApplicationBootstraphooks, a repository subclass that inherits an@Optional()constructor dependency, aGET /users/meroute declared afterGET /users/:id, and Jest e2e tests with supertest. - Upgrade: CLI 12.0.8
nest upgrade, which installed NestJS 12.1.3,@nestjs/config12.0.1 and Joi 18.3.0. - Runtime: Node.js 24.22.0 on an 8 vCPU Xeon VM. Node.js 26.12.0 and Bun 1.4.3 were used for the cross-runtime checks.
- Benchmarks: hyperfine for startup and tooling times (20 runs for startup, 5 to 8 for tools). For HTTP I used oha with 64 connections, taking the median of three 10-second runs after a warm-up, with the server pinned to 2 cores and oha on the other 6.
"Cold start" means the wall time to start the process, boot Nest, bind the port and close it again. Inside the process, NestFactory.create() plus listen() took 70 ms on v11 and 84 to 93 ms on v12. The rest is Node starting and loading modules.
How do I upgrade to NestJS 12?#
The upgrade has five steps. The first trips almost everyone coming from a v11 project.
Step 1: upgrade the CLI (and TypeScript with it)#
The guide says to upgrade the CLI first, both globally and in your project. On my v11 project the local install failed:
$ npm i -D @nestjs/cli@latest @nestjs/schematics@latest
npm error ERESOLVE unable to resolve dependency tree
npm error Found: typescript@5.9.3
npm error peer typescript@">=6.0.0" from @nestjs/schematics@12.0.7@nestjs/schematics 12 needs TypeScript 6 or newer as a peer, and a v11 project has 5.x. Skipping the local install doesn't help either. The global v12 binary hands off to the project's local v11 CLI, which answers Invalid command: upgrade. The fix is to bump TypeScript in the same command:
npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6Use TypeScript 6, not 7. typescript@latest is 7.0.2 now, and the Nest CLI can't build with it yet (more on that below). The schematics also need Node.js 22.22.3+, 24.15+ or 26+, which is a higher floor than running the app.
Step 2: run nest upgrade --dry-run#
npx nest upgrade --dry-runOn my app, the dry run printed this report and changed nothing:
Changes applied:
✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0,
@nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0, @nestjs/config@^12.0.0
✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30)
✔ /src/app.module.ts: moved "validationOptions" settings under "validationOptions.libraryOptions"
✔ Updated "joi" from "^17.13.8" to "^18.0.0" (Joi v18 implements Standard Schema, required by @nestjs/config v12)
Notes:
ℹ Lifecycle hooks are now invoked by component hierarchy level (found in /src/users/users.service.ts)
Please review:
⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on Node.js v24.9+
Action required:
➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011)It's a good report. It found the Joi schema, the config option that needs to move, and the file that uses lifecycle hooks. Add --no-observe (or --observe) to skip the prompt about setting up @nestjs/observe.
Step 3: run nest upgrade#
npx nest upgrade --no-observeThis took 6 seconds including the install. It changed 3 files: package.json, package-lock.json and one line in app.module.ts:
- validationOptions: { allowUnknown: true, abortEarly: true },
+ validationOptions: { libraryOptions: { allowUnknown: true, abortEarly: true } },It doesn't touch your module format, so a CommonJS app stays CommonJS. It doesn't switch you to Vitest or oxlint either.
Step 4: fix the TypeScript 6 errors#
After the upgrade, nest build failed with five errors:
error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set
error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
src/users/create-user.dto.ts:3:14 - error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor.These come from TypeScript 6, not from NestJS. TypeScript 6 turns strict on by default, which brings in strictPropertyInitialization, and that flags every class-validator DTO property. It also deprecates baseUrl and makes rootDir explicit. The v12 project template sets these options:
// tsconfig.json
{
"compilerOptions": {
"rootDir": ".",
"strictPropertyInitialization": false
// and delete "baseUrl": "./" unless you use path aliases
}
}// tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": "./src" },
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}The upgrade report only mentioned tsconfig.build.json. My e2e tests use the base tsconfig.json through ts-jest, and they failed with the same TS5011 error until I added "rootDir": "." there too. If you use path aliases, replace baseUrl with paths entries relative to the tsconfig, or set "ignoreDeprecations": "6.0" for now. My TypeScript 7 upgrade guide covers the rest of the 6.0 and 7.0 changes.
Step 5: make Jest load the ESM packages#
Once it compiled, every Jest suite failed:
Must use import to load ES Module: node_modules/@nestjs/testing/index.js
- Use Node v24.9+ where Jest supports require(esm) nativelyI was already on Node 24.22, and Node 26.12 failed the same way. Jest 30.5.2 only takes its ESM path when vm.SyntheticModule exists, and Node only exposes it behind a flag. Setting that flag fixed all six tests:
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"(Use cross-env if you need Windows support.) The alternative is to move to Vitest, which is what new v12 projects use. More on that below.
Which NestJS 12 breaking changes actually bit?#
@Optional() inheritance depends on the exact v12 version#
The migration guide says a subclass without its own constructor no longer inherits its parent's @Optional() markers, and that Nest now throws UnknownDependenciesException where v11 injected undefined. I tested this directly with @nestjs/testing:
On v11, both subclasses silently got undefined for the missing dependency. 12.1.0 added a lookup that finds the class which actually declares the constructor, which brought the inherited markers back for subclasses without a constructor. A subclass that redeclares its constructor without @Optional() throws on every v12 release, and on early 12.0.x releases the constructor-less subclass throws too. Declare the marker again on the subclass:
@Injectable()
class UsersRepo extends BaseRepo {
constructor(@Optional() @Inject(AUDIT) audit?: AuditLog) {
super(audit);
}
}Lifecycle hook order#
nest upgrade flagged my hooks file. The order of onModuleInit and onApplicationBootstrap across my repository and service was identical on v11 and v12. The change only affects providers at different levels of the component hierarchy. If your teardown or startup code depends on another provider's hook having run, add a test that checks the order.
Route shadowing is still opt-in#
GET /users/me declared after GET /users/:id still returned the :id handler's 400 error on v12, the same as v11. The new options are off by default. Turn them on to get warnings and literal-before-parameter ordering:
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
routeResolutionStrategy: 'specificity',
});With both set, /users/me returned {"me":true}, and Nest logged a WARN line for each literal and parameter route pair that overlaps. I recommend turning both on in new apps.
How do I use Zod for validation in NestJS 12?#
Add a schema to the parameter decorator and register StandardSchemaValidationPipe. No custom pipe or extra package is needed:
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(2),
age: z.number().int().min(13).max(120),
});
export type CreateUser = z.infer<typeof createUserSchema>;
@Controller('zusers')
export class ZUsersController {
constructor(private users: UsersService) {}
@Post()
create(@Body({ schema: createUserSchema }) body: CreateUser) {
return this.users.create(body);
}
@Get(':id')
find(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
return this.users.find(id);
}
}// main.ts: both pipes can run side by side during a migration
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
new StandardSchemaValidationPipe(),
);With both pipes registered globally, class-validator DTO routes and Zod routes work in the same app. Each pipe only acts on parameters it recognises, so you can migrate one controller at a time. Invalid input returns the usual 400 response, with messages prefixed by field path:
{"message":["email: Invalid email address","name: Too small: expected string to have >=2 characters","age: Too small: expected number to be >=13"],"error":"Bad Request","statusCode":400}Unknown keys are stripped by default, as with whitelist: true, because z.object drops them.
On the same app, server and load, POST /zusers with Zod 4.6.7 handled 20,804 req/s, and POST /users with class-validator handled 16,237 req/s. That makes Zod 28% faster, with p99 latency of 6.2 ms against 7.3 ms. Zod also gives you a single source for the TypeScript type, the validation and the OpenAPI schema. On v12 I'd use it for new endpoints. class-validator stays fully supported, so there's no need to rewrite working DTOs.

Machine-readable error codes come with v12 too. throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' }) returned {"message":"Password is too weak","error":"Bad Request","statusCode":400,"errorCode":"WEAK_PASSWORD"}, so clients can branch on a stable code instead of parsing the message.
Is NestJS 12 slower than NestJS 11?#
Request handling is not slower. Startup is.
Throughput moved by 5% or less in either direction, which is within the run-to-run variation. Cold start grew by about 90 ms (30%) in a CommonJS app, and most of that is the framework's own import cost: 50 ms more just to load the three core packages. The CJS app's require(esm) path is not the main cause, because the same app converted to ESM was only 23 ms faster. Adding Zod and a second controller added another 44 ms.
For a long-running API, 90 ms at boot doesn't matter. For serverless and scale-to-zero it does, and two cheap fixes help:
- Enable Node's compile cache. With
NODE_COMPILE_CACHE=/tmp/node-cache, v12 started in 338 ms instead of 386 ms, and v11 in 257 ms. - Run on Bun. The same v12 ESM build started in 264 ms on Bun 1.4.3, faster than v11 on Node.
Node 26.12 started the v12 app in 355 ms, close to Node 24 with the compile cache.
Should I convert my NestJS app to ESM?#
You don't have to: nest upgrade leaves CommonJS apps alone, and they run fine. Converting gave me 23 ms of startup and nothing else measurable. Do it when a dependency you need is ESM-only and uses top-level await (which require(esm) can't load), or when you want Vitest without extra configuration.
The conversion took me ten minutes on a small app:
- Add
"type": "module"topackage.json. Projects generated with@nestjs/schematics11.0.6+ already use"module": "nodenext", so tsconfig needs no other change. - Add
.jsto every relative import ('./app.module.js'). A one-line regex changed 9 files. - Replace
__dirnamewithimport.meta.dirnameand callawait bootstrap(). - Fix namespace imports of CommonJS packages. My build passed, but startup crashed with
TypeError: Joi.string is not a functionuntil I changedimport * as Joi from 'joi'toimport Joi from 'joi'. TypeScript doesn't catch this one, so start the app after converting.
Jest vs Vitest and ESLint vs oxlint in NestJS 12#
A new v12 project (nest new, which defaulted to ESM when run without a TTY) gets this package.json: "type": "module", vitest 4 with vite 8, oxlint plus oxlint-tsgolint for type-aware rules, and TypeScript 6. There's no SWC plugin. I copied the template's Vitest config into my converted app, and all six e2e tests passed unchanged, including DI that depends on decorator metadata.
Jest got 33% slower on v12 because of the ESM loading path. Vitest was 40% faster than that, and 20% faster than Jest on v11. oxlint was 5.5 times faster than ESLint on this small codebase, and the gap grows with more files. It's not a drop-in replacement, though. ESLint reported 61 problems (35 of them Prettier formatting and 25 no-unsafe-* rules), while oxlint reported one warning that ESLint didn't (no-misused-spread). Keep Prettier as a separate step if you move to oxlint.
Does NestJS 12 support TypeScript 7?#
You can't build NestJS 12 with TypeScript 7 through the Nest CLI yet. With typescript@7.0.2 installed, nest build stops with:
Error The installed TypeScript version (7.0.2) does not expose the programmatic compiler API that the Nest CLI requires.
TypeScript 7.0 ships the "tsc" executable only; the compiler API is expected to return in 7.1.
Please install TypeScript 6 (e.g. "npm i -D typescript@^6") until then.nest build -b swc stops with the same error. Plain tsc -p tsconfig.build.json from TypeScript 7 compiled the app correctly, and the output booted normally. So if you want the 3x faster type-check now, run tsc 7 for type-checking and builds in CI, and keep TypeScript 6 for nest start --watch until 7.1.
Can I run NestJS 12 on Bun?#
Yes. The compiled ESM app ran unchanged with bun dist/main.js, including class-validator, Joi config and Express. Under the same load test, Bun 1.4.3 beat Node 24.22:
The v12 CLI also accepts bun as a package manager. For the broader trade-offs (memory over time, APM agents, native addons), see my Bun vs Node benchmark.

NestJS 12 upgrade checklist#
- Make sure you're on Node.js 22.22.3+, 24.15+ or 26 for the CLI. The app itself needs only 20.19+ or 22.12+.
- Run
npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6. - Run
npx nest upgrade --dry-runand read the report. - Run
npx nest upgrade --no-observeand review the diff. - tsconfig: add
rootDirto both files, removebaseUrl, and setstrictPropertyInitialization: falseif you use class DTOs. - Tests: add
NODE_OPTIONS=--experimental-vm-modulesfor Jest, or move to Vitest. - Search for subclasses that redeclare a constructor and add
@Optional()where needed. - Run the app and hit every route, especially if you use NATS, GraphQL subscriptions, Terminus or custom pipes.
- Optionally turn on
routeConflictPolicyandrouteResolutionStrategy: 'specificity', write new endpoints with Zod, and setNODE_COMPILE_CACHEin production.
On AWS Lambda's Node 20, 22 and 24 runtimes, require(esm) is off by default. Set NODE_OPTIONS=--experimental-require-module there for a CommonJS app.
Common mistakes when upgrading to NestJS 12#
- Installing
typescript@latest. That's 7.0, and the Nest CLI refuses to build with it. Pin^6until TypeScript 7.1. - Upgrading only the global CLI. The global binary hands off to the project's local v11 CLI, which doesn't know
upgrade. - Trusting the "Node 24.9+" Jest note. On Node 24.22 and 26.12, Jest 30 still needed
--experimental-vm-modulesto load@nestjs/testing. - Fixing only
tsconfig.build.json. Tests compile withtsconfig.json, which needsrootDirtoo. - Converting to ESM and keeping
import * as Xfor CommonJS packages. It compiles and then crashes at startup. - Missing redeclared
@Optional()markers. A subclass that redeclares its constructor now throwsUnknownDependenciesExceptioninstead of injectingundefined, and 12.0.0 to 12.0.4 also throw for subclasses without a constructor. Use 12.1.x and re-mark optional parameters. - Rewriting every DTO to Zod at once. Both pipes run side by side. Migrate one controller at a time.
Related: if you're choosing a database layer for your Nest API, read Drizzle vs Prisma in 2026. If you're exposing the API to AI agents, read MCP vs API.
Key takeaways#
- NestJS 12 shipped on August 27, 2026, with ESM-only packages, Standard Schema validation, a rebuilt CLI and
@nestjs/observe. 12.1.3 is current. nest upgrade --dry-rungives an accurate report, but upgrade the local CLI together withtypescript@^6or npm fails with ERESOLVE.- After the upgrade, fix TypeScript 6's
rootDir(in both tsconfigs),baseUrland strict DTO errors, and give Jest--experimental-vm-modules. - Throughput stayed within 5% of v11. Cold start grew by about 90 ms, and
NODE_COMPILE_CACHEor Bun wins most of it back. - Zod through
@Body({ schema })was 28% faster than class-validator, 20,804 vs 16,237 req/s, and both pipes can run in one app. - Vitest ran the same e2e suite 40% faster than Jest on v12, and oxlint linted 5.5 times faster than ESLint.
- TypeScript 7 can type-check and build a Nest 12 app with plain
tsc, butnest buildneeds TypeScript 6 until 7.1.
Questions
Share


