NestJS v12 Is Great, and Nest Was Always This Flexible
Hey folks,
NestJS has been my backend framework of choice for years. It’s behind pretty much everything I build, from Varsafe to Glowo to most of my side projects. So when the v12 release plan dropped, I read every line of it, and I came away even more convinced that Nest is one of the best-designed frameworks out there. This article is me telling you why, using the v12 announcement as the perfect excuse.
A couple of months ago NestJS published the v12.0.0 release plan (a draft, targeting early Q3 2026), and the community was super enthusiastic. Native ESM, Vitest by default for new ESM projects, Standard Schema in route decorators (pass a Zod schema through the new schema option on @Body and friends), Webpack swapped for Rspack, a full website redesign. Scroll the thread and you feel the energy:
“Native ESM support, finally! 🥳”
”🔥 native ESM support 🔥🔥🔥🔥🔥🔥🔥”
“Can’t wait to replace class-validator with zod for stable maintenance”
“Christmas coming early this year! 🥳”
That PR is sitting on 170+ ❤️, 140+ 🎉 and 100+ 🚀. People are genuinely happy, and they should be. These are the right moves and they’ll make the default Nest experience noticeably better, especially for newcomers who’ll get a modern setup out of the box.
And here’s what makes me appreciate NestJS even more reading this list: almost none of it is a surprise. Every headline feature is the framework officially embracing something its design already let you do.
That’s the real story, and it’s a compliment: Nest was built so that all of this was possible long before it shipped as a default.
Its superpower was never just the batteries included, it’s how extensible it is. Pipes, interceptors, guards, custom decorators, providers, the whole composition model is open by design. So the great news is double: the built-ins are a fantastic default that make the happy path even happier, and the extensibility meant you were never blocked waiting for them. v12 is Nest leaning harder into both at once. Let me show you what I mean with the things people are most excited about.
Zod: I’ve been running it in prod for 3+ years
The loudest validation request in that thread is “give me Zod instead of class-validator.” Look closer and you’ll notice something:
“you can do that today with some plumbing code”
And they’re right. It’s not even much plumbing. A NestJS pipe is just a class with a transform method, so a Zod validation pipe is the most natural thing in the world. There’s also been a popular community package, nestjs-zod, doing this for ages, which is exactly why people in the thread ask whether v12 will build on it.
I run a few flavors of Zod validation across my work over the past few years. Let me show them. (All three are runnable and tested in a companion repo: iiAku/nestjs-zod-validation.)
1. The normal, basic one
The classic single-schema pipe. Everybody writes this once and reuses it forever. It takes a schema, validates whatever flows through it, and hands back the parsed (and now correctly typed) value:
import { Injectable, type PipeTransform } from "@nestjs/common";
import type { ZodType } from "zod";
import { ValidationError } from "./errors";
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new ValidationError(result.error.message);
}
return result.data;
}
}
Used like this:
@Post()
create(@Body(new ZodValidationPipe(createMonitorSchema)) body: CreateMonitor) {
return this.monitors.create(body);
}
That’s it. One schema is the single source of truth: it validates at runtime and gives you the static type via z.infer. No duplicated DTO class, no decorators on every field, no class-transformer doing reflection magic behind your back.
2. The HTTP-based one (path / query / body, and headers)
The single-schema pipe is fine when you only validate a body. But a real endpoint often has a typed param, a typed query, and a typed body. Binding a pipe per argument gets repetitive fast. So the version I actually run is metadata-aware: one pipe instance, one declaration per route, validating every part of the request from the right schema.
This is the real HttpZodPipe from Glowo (Zod 4, using the new top-level z.flattenError / z.prettifyError helpers):
import { type ArgumentMetadata, Injectable, type PipeTransform } from "@nestjs/common";
import { ValidationError } from "../errors/errors";
import { z } from "zod";
interface HttpZodPipeSchemas {
body?: z.core.$ZodType;
query?: z.core.$ZodType;
param?: z.core.$ZodType;
headers?: Record<string, z.core.$ZodType>;
}
const toValidationError = (error: z.ZodError, field: string): ValidationError => {
const flattened = z.flattenError(error);
return new ValidationError(z.prettifyError(error), field, {
formErrors: flattened.formErrors,
fieldErrors: flattened.fieldErrors as Record<string, string[]>,
});
};
@Injectable()
export class HttpZodPipe implements PipeTransform<unknown> {
constructor(private readonly schemas: HttpZodPipeSchemas) {}
async transform(value: unknown, metadata: ArgumentMetadata): Promise<unknown> {
switch (metadata.type) {
case "body":
case "query":
case "param": {
const schema = this.schemas[metadata.type];
if (!schema) return value;
const result = await z.safeParseAsync(schema, value);
if (!result.success) {
throw toValidationError(result.error, metadata.type);
}
return result.data;
}
case "custom": {
if (!metadata.data || !this.schemas.headers) return value;
const headerName = String(metadata.data).toLowerCase();
const schema = this.schemas.headers[headerName];
if (!schema) return value;
const result = await z.safeParseAsync(schema, value);
if (!result.success) {
throw toValidationError(result.error, `header:${headerName}`);
}
return result.data;
}
default:
return value;
}
}
}
metadata.type tells the pipe which part of the request it’s looking at, so a single declaration covers params, query and body at once:
@Get("id/:id")
@UsePipes(new HttpZodPipe({ param: idParamSchema, query: previewQuerySchema }))
getById(@Param() { id }: IdParam, @Query() { preview }: PreviewQuery) {
return this.publicStatus.getById(statusPageId(id), preview);
}
@Post(":slug/subscribe")
@UsePipes(new HttpZodPipe({ param: slugParamSchema, body: emailSubscribeSchema }))
subscribe(@Param() { slug }: SlugParam, @Body() body: EmailSubscribe) {
return this.subscribers.subscribe(slug, body);
}
One pipe, full request validated, each argument carrying the type you annotated it with (keep that annotation in sync with the schema via z.infer), errors carrying the field they came from. I’ve been doing this in production for over three years across multiple services, and the fact that Nest let me build it so cleanly is exactly the point I’m making.
And here is where v12 genuinely raises the bar, not just blesses my habit. The new schema option on route decorators is not “Nest now supports Zod.” It’s built on Standard Schema, a tiny spec that Zod, Valibot, ArkType, Effect Schema and a growing list of others all implement. So Nest isn’t picking a validation library for you. It adopts the interface and lets you bring whichever one you like, and swap it later without touching your controllers. That is the Nest philosophy, unopinionated where it counts, turned into a first-class feature. Honestly, for me that’s better than a built-in Zod pipe would ever have been, because it keeps the choice in my hands while removing the boilerplate. Kamil even confirms in the thread the built-in pipe “doesn’t rely on mixins & design metadata”, and that you can keep using nestjs-zod if you prefer. A sane default, not a cage. That’s Nest at its best.
It also quietly answers a real ask in the community. Some folks want validation decoupled from class-validator and class-transformer. With Standard Schema baked in, you get a clean, officially-supported path to do exactly that, while everyone happy with class-validator keeps it. Nobody loses. Everybody gets a choice. Very Nest.
3. The one that isn’t tied to HTTP at all
Here’s the part the “built-in” framing tends to hide. A pipe is just a class with a transform method. Nothing about it is HTTP. The exact same idea validates a message off a broker the moment it lands, before any business logic runs, on a service that has no HTTP layer in that path at all.
Picture a microservice consuming a user.registered topic. The validator is, again, just a Zod pipe. The only real difference from the HTTP one is what it throws on failure: an RpcException instead of an HTTP error, because there’s no request/response here, there’s a message and a broker.
There’s one realistic wrinkle: brokers often hand you the payload as a raw JSON string rather than a parsed object, so the pipe has to cope with both. The naive fix is a typeof value === "string" ? JSON.parse(value) : value inside transform, but a bare JSON.parse throws a SyntaxError that sidesteps your validation path entirely. So I lean on a small helper I actually use in my projects’ core, parseResponse, which decodes a string then validates, or validates an object directly, and turns malformed JSON into a normal Zod issue instead of a thrown SyntaxError. It’s built on a Zod 4 codec:
import { z } from "zod";
// decode JSON string → validate; encode value → JSON string
export const jsonCodec = <T extends z.ZodType>(schema: T) =>
z.codec(z.string(), schema, {
decode: (jsonString, ctx) => {
try {
return JSON.parse(jsonString);
} catch (err: unknown) {
ctx.issues.push({
code: "invalid_format",
format: "json",
input: jsonString,
message: err instanceof Error ? err.message : "Invalid JSON",
});
return z.NEVER;
}
},
encode: (value) => JSON.stringify(value),
});
// strings are decoded as JSON then validated; objects are validated directly
export const parseResponse = <T extends z.ZodType>(body: unknown, schema: T) =>
typeof body === "string" ? jsonCodec(schema).safeDecode(body) : schema.safeParse(body);
The pipe then stays a thin transport adapter, it hands the value to parseResponse and decides which exception to throw:
import { Injectable, type PipeTransform } from "@nestjs/common";
import { RpcException } from "@nestjs/microservices";
import type { ZodType } from "zod";
import { parseResponse } from "./json-codec";
@Injectable()
export class RpcZodPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = parseResponse(value, this.schema);
if (!result.success) {
throw new RpcException(result.error.message);
}
return result.data;
}
}
The only real difference from the HTTP pipe is the exception it throws: an RpcException, so the microservice exception layer handles it instead of an HTTP filter (what happens next, retries, dead-letter, depends on your transport and broker config). The caller doesn’t wrap anything, it binds the pipe right on the payload, just like @Body(pipe) on an HTTP controller, here on an @EventPattern consumer with @Payload():
@Controller()
export class UserRegisteredController {
constructor(private readonly registrations: UserRegistrationHandler) {}
@EventPattern("user.registered")
handle(@Payload(new RpcZodPipe(userRegisteredSchema)) event: UserRegistered) {
return this.registrations.handle(event);
}
}
Same pipe, same idea, different transport. event carries the UserRegistered type you annotated (z.infer<typeof userRegisteredSchema>), and a malformed message, whether it’s bad JSON or a valid object that breaks the schema, never reaches registrations.handle. Single source of truth, once again.
This is the bit I really want to land. Validation in Nest was never an HTTP feature. The pipe abstraction is transport-agnostic, so the same Zod-everywhere discipline I use on REST endpoints carries straight over to message consumers, with no framework feature needed for it. v12’s built-in StandardSchemaValidationPipe will probably make the HTTP case a one-liner, and maybe it’ll be trivial to spin into non-HTTP transports too. That’s genuinely nice. But “doable” was never in question, it’s been running in production on my message consumers for a long time.
Bun: it’s worked for a long time (and it already solved “the ESM issue”)
A lot of people have been asking for Bun support, and v12 leans into faster, modern tooling. Here’s the fun part, straight from Kamil in the same thread:
“AFAIK bun is already supported. you can also use OXC compiler with no issues” — @kamilmysliwiec
I’ve been running Bun + NestJS for a long time now. It’s the runtime behind the Varsafe CLI (Bun + NestJS + Commander, compiled straight to a binary), and I reach for it first on every side project. I wrote more about why in my 2026 stack & tooling post.
Now, to the question I keep seeing: is Bun solving the ESM issue?
Kind of, yes, for you, if you were already on Bun. Bun executes ESM, CommonJS and TypeScript natively, in the same process, without a build step. The CJS↔ESM interop tax that has been hurting the Node world for years mostly doesn’t bite you, because Bun resolves both module systems for you. So the ESM migration that v12 is rightly celebrated for? If you were on Bun, it was largely a non-issue already.
That doesn’t make the v12 ESM work pointless, far from it. It’s the genuine headline of the release, and it matters enormously for the Node runtime and for the growing pile of ESM-only libraries you simply couldn’t require cleanly before. As Kamil notes, the missing piece that finally made the migration practical was Node’s require(esm) support (with its own constraints, like synchronous-only modules), without it, the move wouldn’t have made much sense. So it’s the right call. I’m just saying: depending on your runtime, you may have already been living in the future.
Vitest: “now included,” but it always worked
Same story with testing. v12 makes Vitest (with OXC for the TypeScript decorator metadata) the default for new ESM projects, and the thread is happy about it.
But Vitest is just a test runner. Nothing ever stopped you from wiring it into a Nest project; plenty of us have been testing Nest apps with Vitest for a long while. Personally I run Vitest in the Node world and bun:test in the Bun world, and both have been completely fine with Nest. v12 making it the default is a quality-of-life win for the next person scaffolding a project, not a capability that suddenly appeared.
So what’s the actual takeaway?
Don’t read this as “the release doesn’t matter.” It does. Sensible defaults are a feature. They lower the barrier for newcomers, they cut boilerplate, they bless good patterns, and they mean the next junior dev who scaffolds a new ESM project lands on ESM + Vitest + Standard Schema without having to discover all of it the hard way. That’s worth celebrating, and the community is right to be hyped.
And the thing I love most is the continuity underneath all the excitement: Nest was always this flexible. Pipes, interceptors, guards, custom decorators, module providers, the whole composition model has let you bolt on Zod, run on Bun, test with Vitest, and ship ESM-friendly code for years. The v12 built-ins aren’t Nest fixing a gap. They’re Nest taking the patterns its own extensibility made possible and turning them into first-class, polished defaults. That is a framework that knows exactly what it is.
So I’m genuinely excited for v12, and I’ll happily adopt the new defaults the day they land. But the deeper reason I keep reaching for NestJS is the same reason all of this was possible before the release: it’s built to get out of your way and let you compose whatever you need. The built-ins are the cherry on top. The cake was always this good.
Sources
- NestJS v12.0.0 release plan (PR #16391), the source for the roadmap and every quote in this article
- The original v12 announcement on LinkedIn
- Standard Schema, the spec behind the new
schemaoption (Zod, Valibot, ArkType, and more) nestjs-zod, the community package that has done Zod-in-Nest for yearsrequire(esm)in Node.js by Joyee Cheung, the piece that made the ESM migration practical- iiAku/nestjs-zod-validation, the companion repo with every pipe in this article, runnable and tested (NestJS 11 + Zod 4)
Get notified when I publish something new. No spam, unsubscribe anytime.