This article is published in English.
NestJS 12 Migration Guide: ESM, Standard Schema, and Observability
This guide breaks down NestJS 12's core changes—ESM packages, Standard Schema validation, built-in observability, and CLI updates—and how to migrate safely.
NestJS 12 has arrived, and unlike a typical major version bump, this release doesn't revolve around a single headline feature.
Instead, it touches several corners of the NestJS ecosystem at once, refreshing them for how backend development actually looks today.
Among the most notable updates are:
- Nest packages now distributed as ESM
- Validation built around Standard Schema
- Serialization built around Standard Schema
- Built-in observability through
@nestjs/observe - A rewritten NestJS CLI
- Rspack support for new monorepo setups
- Vitest and oxlint available by default in new projects
- Smarter detection of conflicting routes
- Error codes that tooling can parse
- Structured, machine-friendly logging
If you already maintain a NestJS codebase, there's one detail that should ease any worry right away:
Switching your app to ESM is not required just because NestJS 12 itself is distributed as ESM.
That single fact turns what could look like a disruptive upgrade into something you can adopt at your own pace.
NestJS 12 Focuses on Bringing the Framework Up to Date
NestJS is widely used for building well-organized backend services on top of Node.js and TypeScript.
Its overall structure hasn't changed and remains recognizable to anyone who's used it before:
NestJS 12 Is About Modernizing the Framework
NestJS has become one of the popular ways to build structured backend applications with Node.js and TypeScript.
Its architecture is familiar:
What has shifted is the surrounding Node.js landscape.
ESM adoption keeps growing across the ecosystem.
Schema libraries such as Zod are gaining traction.
Newer, faster bundlers are taking over from legacy build tools.
Observability is increasingly treated as a first-class concern during development, not something bolted on after a service ships.
NestJS 12 is essentially catching up with all of these trends at once.
What's notable, though, is that none of this forces existing applications to adopt everything on day one.
1. Core Nest Packages Now Ship as ESM
Perhaps the most visible change in this release is that Nest's core packages are now published in ESM format.
If your project is built on CommonJS, that might sound like it demands a large rewrite.
Fortunately, modern versions of Node.js support require(esm).
In practice, that means most CommonJS applications can keep running as-is, without a full conversion to ESM.
For instance, this line still works exactly as before:
const { NestFactory } = require('@nestjs/core');
You are not required to rewrite it as:
import { NestFactory } from '@nestjs/core';
That said, NestJS 12 does raise the minimum Node.js version you need.
Specifically, you'll need one of:
Node.js 20.19+
or
Node.js 22.12+
Node.js 21.x is explicitly not supported.
So before touching your NestJS dependencies, confirm which Node version you're running:
node --version
Checking this in your CI/CD pipeline early on is a sensible precaution too.
2. Moving to ESM Is a Choice, Not a Requirement
This point deserves special emphasis for teams maintaining existing projects.
Two separate migrations are happening here.
NestJS itself is transitioning its packages to ESM.
Your application, however, is under no obligation to follow immediately.
In other words, this setup is perfectly valid:
Existing CommonJS Application
↓
NestJS 12
↓
Continue running CommonJS
rather than being forced into:
CommonJS
↓
Rewrite everything
↓
ESM
↓
NestJS 12
That said, custom tooling around your project can still introduce friction.
It's worth double-checking:
- custom bootstrap scripts
- build pipelines
- test runners
- bundler configuration
- non-standard import patterns
- tooling tied specifically to CommonJS
Even if NestJS itself works fine, a script or tool you rely on elsewhere in the pipeline might not.
3. Standard Schema Support Changes the Validation Story
Among the more notable additions in NestJS 12 is built-in support for Standard Schema.
If you've spent time with TypeScript lately, chances are you've come across libraries like Zod, Valibot, or ArkType. These tools handle runtime validation while integrating cleanly with TypeScript's type checking.
Historically, NestJS leaned on class-based DTOs paired with class-validator. That pattern still works and isn't being removed. NestJS 12 simply adds an alternative path.
Here's what that looks like in practice:
@Post()
create(
@Body({
schema: createUserSchema,
})
body: CreateUserDto,
) {
return this.usersService.create(body);
}
You then wire it up globally:
app.useGlobalPipes(
new StandardSchemaValidationPipe(),
);
With this in place, the schema itself takes on the job of validating incoming requests. This is especially handy if your codebase already defines schemas using Zod or another library that follows the Standard Schema spec.
4. Zod Integrates More Directly With NestJS
Suppose you already have a Zod schema defined like this:
const createUserSchema = z.object({
name: z.string().min(1),
email: z.email(),
});
Rather than duplicating that logic into a separate NestJS-specific validation layer, you can plug the existing schema directly into the request boundary.
The same applies to route parameters:
@Get(':id')
findOne(
@Param('id', {
schema: z.coerce
.number()
.int()
.positive(),
})
id: number,
) {
return this.usersService.findOne(id);
}
This cuts down on repeated logic. Instead of keeping one validation ruleset for the client and another for the server, teams can share a single schema wherever their setup permits it. As a bonus, these schemas can also feed into OpenAPI documentation generation.
5. Standard Schema Also Applies to Outgoing Responses
Validation isn't limited to what comes in — what goes out matters too.
Consider a case where an endpoint accidentally returns something like:
{
"id": 1,
"name": "John",
"passwordHash": "..."
}
Technically, the handler did return an object. But that object may expose more than the API contract was meant to reveal.
To address this, NestJS 12 ships StandardSchemaSerializerInterceptor, which checks and reshapes outbound data before it reaches the client.
For instance:
@UseInterceptors(
StandardSchemaSerializerInterceptor,
)
@SerializeOptions({
schema: userResponseSchema,
})
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
The result is validation coverage on both ends of the request cycle:
Client
↓
Request
↓
Schema Validation
↓
Application
↓
Schema Serialization
↓
Response
↓
Client
For services built primarily around APIs, that symmetry is a meaningful improvement.
6. Built-In Observability Through @nestjs/observe
NestJS 12 also debuts a dedicated observability package:
@nestjs/observe
What sets it apart is that it's aware of NestJS's internal structure. A typical monitoring agent would only see something like:
POST /users
200
Nest's own tooling, by contrast, can recognize higher-level constructs such as controllers, providers, GraphQL resolvers, queue consumers, jobs, and microservices.
The instrumentation extends across several areas, including HTTP, GraphQL, gRPC, microservices, queue consumers, and cron jobs.
The goal is to treat observability as something woven into the application's lifecycle, rather than something bolted on externally at the HTTP server level.
7. Observability Is Something QA Should Care About
From a QA perspective, this observability layer deserves attention.
Testing shouldn't stop the moment an API sends back a response:
200 OK
There's value in understanding what actually happened while that response was being produced.
Consider a request flowing through the system like this:
Request
↓
Controller
↓
Service
↓
Database
↓
External API
↓
Response
If a call takes three seconds to complete, a 200 status alone doesn't tell the full story.
What you really want to know is where those three seconds were spent.
Possible culprits include:
- slow database queries
- delays from an external API
- time spent in application logic
- queue backlog
- unexpected retry attempts
Observability data gives QA and engineering teams an additional layer of evidence to work with, helping tie together test failures and real production behavior.
8. Config Validation Shifts to Standard Schema
Configuration handling is another area getting a refresh.
Historically, a lot of NestJS apps relied on Joi for this:
ConfigModule.forRoot({
validationSchema: schema,
});
With NestJS 12, config validation is shifting toward Standard Schema instead.
Here's what that looks like in practice:
ConfigModule.forRoot({
validationSchema: z.object({
NODE_ENV: z
.enum([
'development',
'production',
'test',
])
.default('development'),
PORT: z.coerce
.number()
.default(3000),
}),
});
Joi hasn't been dropped — existing projects can keep using it, but they'll need to upgrade to Joi 18 or newer and relocate any library-specific settings under:
validationOptions.libraryOptions
This is part of a broader push toward a shared schema interface across the framework's ecosystem.
9. Conflicting Routes Can Now Be Caught Automatically
There's a subtle API design pitfall that's often hard to spot until it bites you.
Suppose you define these two handlers:
@Get(':id')
findOne() {}
@Get('me')
getCurrentUser() {}
Depending on how routes get resolved and the order they're declared in, a request to:
/users/me
might end up matching the pattern:
/users/:id
rather than hitting the dedicated /me handler as intended.
NestJS 12 adds an opt-in diagnostic feature for spotting this kind of route ambiguity.
You enable it like this:
const app = await NestFactory.create(
AppModule,
{
routeConflictPolicy: {
duplicate: 'error',
shadow: 'warn',
},
routeResolutionStrategy:
'specificity',
},
);
This lets developers catch ambiguous routing rules proactively, rather than stumbling onto them through a confusing API response later.
10. Error Codes That Machines Can Actually Parse
One more small addition here could end up mattering a lot for anything consuming your API.
Take this exception:
throw new BadRequestException(
'Password is too weak',
);
A frontend developer might be tempted to match on the message text directly:
if (message === 'Password is too weak') {
...
}
That approach is brittle, since wording is subject to change.
A more durable contract is to attach a stable error code instead:
throw new BadRequestException(
'Password is too weak',
{
errorCode: 'WEAK_PASSWORD',
},
);
The client can then check against:
WEAK_PASSWORD
rather than depending on exact message phrasing.
This matters even more once an API has several consumers, such as:
- a web frontend
- a mobile app
- a partner-facing API
- internal services
All of them can rely on the same consistent error identifier instead of parsing human-readable text.
Structured Logging Gets an Upgrade
Logging also sees improvements in this release.
You can now write something like:
logger.log(
'User created',
{
userId: 1,
email: 'foo@bar.com',
},
);
The object argument is treated as structured data attached to that particular log line, rather than just extra text to print.
When JSON output mode is enabled, this structured data shows up under a params key, or it can be flattened directly into the log entry using the flattenParams option.
This matters a lot if your logs feed into a monitoring or observability pipeline. Rather than emitting plain strings that need to be parsed after the fact, your application can emit structured entries that are searchable and filterable from the start.
For instance, a log entry might look like this:
{
"message": "User created",
"params": {
"userId": 1,
"email": "foo@bar.com"
}
}
That format is far easier to query than trying to extract fields out of a plain-text message.
The CLI Got a Rebuild
Another substantial change in this release is a full rework of the CLI.
Its codebase moved to ESM. Its test suite switched from Jest to Vitest. End-to-end coverage was added for CLI commands, and the internal command structure was refactored around typed context objects.
None of this necessarily touches your application code directly. But it's a signal that the modernization effort isn't limited to the runtime itself — the tooling and developer workflow are being brought up to date as well.
nest upgrade Simplifies the Migration Path
The rebuilt CLI ships a new command:
nest upgrade
Before applying anything, you can inspect what it intends to change:
Before running it, you can preview the changes:
This is valuable because a major version bump often touches many small, unrelated configuration details. The upgrade command can automatically handle mechanical changes such as:
- bumping
@nestjs/*package versions - updating webpack configuration
- swapping GraphQL Playground for GraphiQL
- adjusting the GraphQL subscription transport
- updating NATS-related packages
- adjusting
@nestjs/configusage - updating Jest dependencies
- updating Joi dependencies
Once it finishes, it prints a summary of what was changed automatically and what still needs a manual look.
Freshly Generated Projects Start With Modern Defaults
Scaffolding a brand-new project with NestJS 12 now gives you a different starting point.
New monorepo setups default to Rspack as the bundler. New projects use oxlint in place of ESLint. Vitest is now the default test runner for ESM-based projects. Bun is also accepted as a package manager option, alongside the existing choices:
npm
yarn
pnpm
None of this retroactively changes existing projects — that distinction matters. NestJS 12 is simply setting a more modern baseline for anything created going forward, while letting applications that already exist migrate at their own pace.
GraphQL Setups Require Some Care
If you're running a GraphQL application, there's migration work you shouldn't skip.
GraphiQL now replaces GraphQL Playground as the default IDE. More significantly, support for:
subscriptions-transport-ws
has been dropped entirely. You're expected to move to:
graphql-ws
These two protocols aren't compatible with each other at the wire level. That means changing your backend's subscription transport isn't a backend-only change — anything consuming those subscriptions needs to be updated and tested too:
NestJS API
↓
GraphQL Subscription
↓
Web / Mobile Client
Updating a dependency on the server side alone won't be enough to keep things working end to end.
NATS Support Also Shifted
The framework now replaces the nats package with:
@nats-io/transport-node
If your application imports the old package directly, you'll need to update both the dependency and the corresponding import statements.
There are also changes to how packets are handled: payloads are now serialized as JSON strings, and any custom deserializer you've written will receive the full NATS message object rather than a pre-parsed payload. You can read the payload contents using:
msg.json()
If messaging is a core part of your system, this is an area worth calling out specifically in your integration and regression test plans.
17. The Order of Lifecycle Hooks Has Changed
There's another breaking change tied to lifecycle hooks.
In NestJS 12, the order in which lifecycle hooks fire now depends on where a component sits in the hierarchy.
This matters if your app relies on a specific sequence during:
- initialization
- startup
- shutdown
- teardown
Suppose a service brings resources online in this order:
Database
Queue
Cache
External API
and a different service assumes one of those resources is already available. After upgrading, you'll want to confirm that assumption still holds.
This is exactly the type of change that won't necessarily surface as a build failure. Your project can compile without errors and still run differently at runtime.
18. Additional Changes Worth Knowing About
A handful of other adjustments are part of this release.
NestJS 12 also touches:
- the shape of validation error responses
- how gRPC exceptions are handled
- Kafka's regular-expression pattern matching
- request-scoped WebSocket gateways
- the reasons reported on WebSocket disconnects
- pre-request hooks for microservices
- graceful shutdown behavior in Express
- how the HTTP adapter maps errors
Most projects won't touch every one of these. But wherever your app does rely on one of these features, it's worth adding targeted regression tests for it.
19. What Should QA Focus On After Upgrading?
This is arguably the most important question to answer.
Confirming a major framework upgrade went smoothly shouldn't rest on nothing more than running:
npm test
Instead, testing should be broken into distinct areas.
API
Check:
- authentication
- authorization
- validation
- error responses
- route matching
- response serialization
Configuration
Check:
- required environment variables
- invalid values
- default values
- production configuration
- test configuration
GraphQL
Where relevant:
- queries
- mutations
- subscriptions
- GraphiQL
- client compatibility
Microservices
Where relevant:
- NATS
- Kafka
- gRPC
- message serialization
- retries
- exception handling
Observability
If it's turned on:
- HTTP traces
- GraphQL traces
- background jobs
- queue consumers
- errors
- cron jobs
Shutdown
Check:
- SIGTERM handling
- active requests
- database connections
- queues
- background workers
The point isn't to answer:
"Does the app start up?"
It's to answer:
"Does the app still behave correctly at every boundary that matters?"
20. What This Release Means for QA Work
There's a broader trend worth noticing.
Frameworks keep getting more automated. Validation is becoming standardized. Observability is being built in directly. Logs are becoming structured by default. Route conflicts can be caught automatically. Testing tooling keeps getting faster.
None of that eliminates the need for QA. It shifts where QA adds the most value.
Rather than only asking:
"Does this endpoint work?"
QA needs to increasingly ask:
"Is the API contract still correct?" "Are failures observable?" "Are permissions properly enforced?" "Are errors machine-readable?" "Is serialization correct?" "Does the app recover the way it should?" "Does this upgrade change existing behavior?"
The framework can automate certain checks on its own. But a person still has to decide what deserves checking in the first place.
21. A Suggested Path Through a NestJS 12 Migration
Rather than upgrading production directly, start by checking your environment:
node --version
Confirm you're on:
Node 20.19+
or:
Node 22.12+
Next, update the CLI:
npm i -g @nestjs/cli@latest
Preview what the migration would do:
nest upgrade --dry-run
Go through the output carefully. Then apply it:
nest upgrade
After that, run:
npm test
along with your integration and end-to-end suites.
Pay particular attention to any functionality built on:
- GraphQL
- NATS
- configuration validation
- custom pipes
- lifecycle hooks
- Webpack
- CommonJS-based tooling
Each of those areas carries migration considerations worth checking individually.
Final Thoughts
NestJS 12 is not simply a matter of tacking on another feature.
It represents a step toward aligning NestJS with the current direction of the Node.js and TypeScript ecosystem.
ESM is now woven into the package architecture itself.
Standard Schema opens the framework up to Zod, Valibot, ArkType, and other validation libraries.
That same schema ecosystem can also be used for serialization.
Observability is now tied more directly into Nest's own application structure.
The CLI has been reconstructed around newer tooling.
Rspack, Vitest, oxlint, and Bun are becoming part of what a modern NestJS setup looks like.
At the same time, none of this forces existing applications to change everything overnight.
You're free to stay on CommonJS.
You can keep relying on class-based validation.
Switching to Vitest or oxlint isn't mandatory right away.
That flexibility is arguably the most practical aspect of this release.
NestJS 12 brings the framework up to date without demanding that every existing app modernize all at once.
For developers, this translates into more room to choose their own pace.
For QA engineers, it means yet another major framework upgrade that has to be verified — not just at the code level, but across APIs, integrations, observability, configuration, and real production behavior.
That's precisely where framework upgrades get interesting.
Bumping the version number in package.json is the easy part.
What actually matters is whether the application keeps performing the way people relying on it assume it will.