Home / Articles / NestJS 12 Migration Guide: ESM, Standard Schema, and Observability

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.

3328 words

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/config usage
  • 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.