Upgrading to NestJS 12: The ESM Switch Is Less Scary Than It Looks
The NestJS 12 release notes landed in late August with "ESM-only core packages" in the headline, and my first reaction was to close the tab. I have an old NestJS API in production that still compiles to CommonJS, and the thought of rewriting import statements across a few hundred files on a weekend sounded miserable. So I waited, watched a couple of other teams go through it, and did my own upgrade last Saturday.
Total time: about an hour. Most of it was reading, not coding. If you've been putting this off, here's what the move actually involves.
The scary headline that mostly isn't
ESM-first sounds like it forces a rewrite of your app. It doesn't. All the core @nestjs/* packages now ship as ESM, but modern Node.js supports require(esm), so a CommonJS application can consume them without touching your own module code. The nest upgrade command deliberately leaves your module format alone.
The real catch is Node version, and there are two different floors. Running a Nest 12 app needs Node 20.19+ or 22.12+ on the 22 line, because that's where require(esm) became unflagged. The CLI's generators need more: 22.22.3+, 24.15+, or 26. The 21.x line never got require(esm) at all and isn't supported. Check your CI image and your deploy container before anything else. This is where the upgrade fails for most people, not in application code.
node -v # do this on CI and prod, not just your laptop
The upgrade drill
The flow is two commands:
npm i -g @nestjs/cli@latest @nestjs/schematics@latest
nest upgrade --dry-run
Run the dry run first. It bumps every @nestjs/* package to v12 in one shot, applies the mechanical migration for you (webpack options in nest-cli.json, the GraphQL playground rename to graphiql, the NATS package swap, config validation options, Jest and Joi bumps), and prints a report of what it changed plus what still needs a human. My dry run flagged exactly one manual item, and I'd bet yours flags the same one if you run a monorepo.
The one place I had to think: webpack
Rspack is now the default bundler for NestJS monorepos, and webpack-centric CLI options are deprecated. --webpack and --webpackPath are on the way out, with --rspackPath taking over for custom configs. Regular single projects still build with tsc, so most people feel nothing here.
If you do have a webpack.config with custom plugins, budget real time for this part. I didn't port my old config line by line. I took the Rspack default and re-added the two plugins that had direct Rspack equivalents. If you've followed the Rust bundler story over the past year, this is the same movie again: webpack exits stage left. I wrote about pnpm 12's Rust rewrite a few days ago, and honestly the ecosystem is not being subtle about it.
Zod in the decorators, which is why I upgraded
Here's the feature I was actually waiting for. @Body(), @Query(), @Param(), and @RawBody() now take a schema option that accepts anything Standard Schema compatible, so Zod, Valibot, and ArkType all work:
@Post()
create(@Body({ schema: createCatSchema }) dto: CreateCatDto) {
return this.catsService.create(dto);
}
Inline coercion is my favorite trick, because query params arrive as strings and that used to mean a transform pipe for every numeric ID:
@Get(':id')
findOne(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
return this.catsService.findOne(id);
}
Two things that aren't obvious from the announcement. First, the decorator only attaches metadata, so you still need to register the pipe yourself:
app.useGlobalPipes(new StandardSchemaValidationPipe());
Second, the same schemas feed OpenAPI generation. That's the part that kills real boilerplate: you can drop the parallel class DTOs instead of maintaining Zod schemas and class-validator classes for the same shapes. class-validator isn't going anywhere, and for the legacy half of my codebase I left it alone. But the z.coerce.number() pattern went straight into my snippet library, because I keep forgetting the exact spelling of it.
Small stuff worth knowing
The structured logging change is trivial and I use it more than anything else in this release:
this.logger.log('User signed in', { userId: 1, method: 'oauth' });
Plain objects after the message become structured params instead of being stringified into oblivion. Searchable logs without a custom wrapper.
Route conflicts finally surface too. Everyone has a @Get(':id') that silently shadows a @Get('me') depending on declaration order, and now you can make Nest yell about it:
const app = await NestFactory.create(AppModule, {
routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
routeResolutionStrategy: 'specificity',
});
One caveat if you use Joi for env validation: it still works, but you need Joi v18+, and library-specific settings move under validationOptions.libraryOptions. There's also a new @nestjs/observe SDK for tracing and a nest deploy command, though I haven't touched either yet.
Should you do it this weekend?
If you've been sitting on the fence because of the ESM headline: your CommonJS app survives. Migrating your own code to ESM is optional and can wait until you actually feel like it. Update Node first, run the dry run, read the report it prints, and only then decide whether your webpack config is worth fighting or worth deleting.
That's the whole job.