NestJS 12 came out on August 28, 2026, and it changes more than a normal major release. The core packages are now ESM. New projects get a different test and lint setup. You can validate requests with Zod. And the docs have a whole new section about reliability.
That means a lot of older "best practices" posts are now only half right.
So this is my list of what I'd do on a new NestJS project today. Each tip comes with the reason behind it and code you can copy. When something is my opinion and not an official rule, I'll tell you.
A quick note on the code: The examples use ESM style, which is what you get when you pick ESM in
nest new. That means imports like./app.module.jsand a top-levelawait. If your project is CommonJS, drop the.jsextensions and callbootstrap()withoutawait.Versions: To run a Nest 12 app you need Node.js 20.19+ or 22.12+. The CLI generators (
nest new,nest generate,nest upgrade) need a newer Node: 22.22.3+, 24.15+ or 26+. The easy answer is to use the latest active LTS.
1. Set up the project on purpose
When you run nest new, the CLI now asks if you want a CommonJS or an ESM project. The choice changes your defaults:
| ESM project | CommonJS project | |
|---|---|---|
| Test runner | Vitest | Jest |
| Linter | oxlint | oxlint or ESLint (sources disagree, see the note below) |
| Compiler | tsc | tsc |
| Bundler for monorepos | Rspack | Rspack |
Heads up: The official sources don't fully agree on the linter. The migration guide says every generated project uses oxlint. The launch post says the CommonJS template keeps ESLint. Open your generated
package.jsonand see what you actually got.
Here's how I'd decide:
New service, nothing legacy: pick ESM. You get the modern defaults from day one.
Existing app: upgrade the packages and stay on CommonJS until you have a real reason to switch.
Staying on CommonJS is fine because Nest's ESM packages can be loaded from CommonJS through require(esm). The upgrade command even leaves your module format alone.
# upgrade the CLI first, globally and locally npm i -g @nestjs/cli@latest @nestjs/schematics@latest npm i -D @nestjs/cli@latest @nestjs/schematics@latest # see what would change without touching files nest upgrade --dry-run # then do it for real nest upgrade nest upgrade moves every @nestjs/* package to v12 at once. That part matters. Keep all Nest packages on the same major version, always. Mixed majors are a classic source of strange errors.
A few things can bite you during the upgrade:
Jest users: Jest can load the ESM-only v12 packages only on Node.js 24.9 or newer. Older versions fail with
ERR_REQUIRE_ASYNC_MODULE. Use Node 24.9+ or move to Vitest.AWS Lambda: The Node 20, 22 and 24 runtimes turn
require(esm)off by default. A CommonJS Nest 12 app needsNODE_OPTIONS=--experimental-require-module.TypeScript jumps to v6:
nest upgraderaisestypescriptto^6.0.0, and it bumps Jest to v30 and Joi to v18 as well. The upgrade schematic flagsmodule: commonjscombined with legacy module resolution, and a missingrootDirintsconfig.build.json(error TS5011). Budget time for new compiler errors.Logger output changed:
ConsoleLoggernow treats extra object arguments as structured params by default. If anything parses your logs, setstructuredParams: falseto get the old output back.Lifecycle hook order changed: Hooks like
onModuleInitnow run by component hierarchy level. If your code depends on the order between providers, test it.@Optional()is no longer inherited: A subclass has to declare it again in its own constructor, or Nest throwsUnknownDependenciesException.Removed or replaced: the old Terminus health indicator API,
subscriptions-transport-wsin GraphQL (usegraphql-ws), and the oldnatspackage (now@nats-io/transport-node).Webpack is deprecated in CLI workflows. Rspack takes over for monorepos.
tscis still the default compiler.
And one rule I like: after upgrading, fix every deprecation warning in your console before you ship. They are cheap to fix now and expensive later.
2. Organize by feature, not by file type
The folder layout I'd start with:
src/ main.ts setup-app.ts app.module.ts config/ env.schema.ts auth/ auth.guard.ts public.decorator.ts common/ domain-error.ts domain-error.filter.ts database/ ... users/ users.module.ts users.controller.ts users.service.ts users.repository.ts db-users.repository.ts dto/ create-user.dto.ts orders/ ... payments/ ... health/ ... Everything about "users" lives in users/. You don't hunt through controllers/, services/ and entities/ folders to change one feature.
Each feature is a module, and modules only talk through what they export:
My rules for modules:
Export as little as possible. Export the service other features need. Don't export repositories or internals.
Avoid one giant
SharedModule. It slowly becomes a junk drawer that everything imports. Small, named modules age better.Treat
forwardRef()as a smell. If two modules need each other, the usual fix is a third module that holds what they share.
3. Put app-wide setup in one place
Here's a habit that saves a lot of "works on my machine" bugs. Put your global setup in one function, and call it from both main.ts and your end-to-end tests.
// src/setup-app.ts import { ValidationPipe, type INestApplication } from '@nestjs/common'; import helmet from 'helmet'; /** App-wide setup. main.ts and the e2e tests both call this. */ export function setupApp(app: INestApplication) { app.use(helmet()); app.enableCors({ origin: ['https://app.example.com'] }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); } // src/main.ts import { NestFactory } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; import { AppModule } from './app.module.js'; import { setupApp } from './setup-app.js'; async function bootstrap() { const app = await NestFactory.create(AppModule, { routeConflictPolicy: { duplicate: 'error', shadow: 'warn' }, routeResolutionStrategy: 'specificity', }); setupApp(app); app.enableShutdownHooks(); const config = app.get(ConfigService); await app.listen(config.getOrThrow<number>('PORT')); } await bootstrap(); I'll explain each line in the sections below. The reason for the shared function is simple: if your tests build the app without your global pipes, they test a different app than the one you ship.
Using Fastify? Use
@fastify/helmetinstead ofhelmet.
4. Keep controllers thin
A controller should do three things: read the input, call a service, return the result. No business rules. No database calls.
// src/users/users.controller.ts import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { Public } from '../auth/public.decorator.js'; // defined in the security section import { CreateUserDto } from './dto/create-user.dto.js'; import { UsersService } from './users.service.js'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Public() @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string) { return this.usersService.findOne(id); } } // src/users/users.service.ts import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto.js'; import { UsersRepository } from './users.repository.js'; @Injectable() export class UsersService { constructor(private readonly users: UsersRepository) {} async create(dto: CreateUserDto) { const existing = await this.users.findByEmail(dto.email); if (existing) { throw new ConflictException('Email is already in use', { errorCode: 'EMAIL_TAKEN', }); } return this.users.insert(dto); } async findOne(id: string) { const user = await this.users.findById(id); if (!user) { throw new NotFoundException('User not found', { errorCode: 'USER_NOT_FOUND' }); } return user; } } Careful with "check, then insert": Two requests can pass the
findByEmailcheck at the same time. Keep a unique index on the email column and treat that database error as the real source of truth. The check in the service is just for a friendly message.
Know where each piece belongs
Nest gives you five tools that wrap a request. People mix them up all the time. This is how I think about them:
| Tool | Its job | Typical use |
|---|---|---|
| Middleware | Low-level request work | Request IDs, raw body handling |
| Guard | "Is this caller allowed?" | Authentication, roles |
| Pipe | "Is this input valid? Convert it." | Validation, ParseUUIDPipe |
| Interceptor | Wrap the handler | Timing, response shaping, resilience |
| Exception filter | Turn errors into responses | A consistent error format |
And this is the order they run in:
If you remember one thing from this picture: guards run before pipes. So a user who isn't allowed in never gets as far as validation.
The dotted lines show where errors go. An exception thrown by a guard, a pipe, an interceptor or the handler ends up in the exception filters. That's how the UnauthorizedException from the auth guard later in this post becomes a clean 401 response.
A route trap worth knowing
Nest registers routes in the order you declare them. On Express, this can quietly break a route:
@Get(':id') // declared first, so it can swallow /users/me findOne(@Param('id') id: string) {} @Get('me') me() {} Version 12 adds two opt-in options to catch this, and I turned both on in main.ts above. routeConflictPolicy can warn or throw on shadowed and duplicate routes. routeResolutionStrategy: 'specificity' picks the most specific route. Both default to the old behavior, so nothing changes until you set them.
5. Let dependency injection stay boring
Nest providers are singletons by default, and that's what you want. This surprises people coming from other languages. Node.js doesn't handle each request in its own thread, so sharing one instance across requests is normally fine.
There is one rule: a singleton must not keep per-request state. If a provider stores "the current user" in a field, two overlapping requests will overwrite each other's value. Keep request data in method arguments, or use AsyncLocalStorage (more on that below).
Avoid request scope unless you really need it
You can make a provider request-scoped, so it gets a fresh instance per request. It's tempting. Here's why I avoid it.
Request scope bubbles up. If your CatsService is request-scoped, then the CatsController that uses it becomes request-scoped too. Now Nest creates and throws away that whole chain on every request. The docs say a well-designed app shouldn't pay more than about 5% latency for this, but "well-designed" is doing a lot of work in that sentence.
Most of the time you only want to read one value that belongs to the current request, like the user, the tenant or the locale. For that, use AsyncLocalStorage. Your providers stay singletons, and the value is still available anywhere downstream. It works the same in HTTP handlers, message handlers and queue jobs.
Multi-tenant app? Nest has "durable providers" for exactly this case. They let you share one DI sub-tree per tenant instead of rebuilding it per request. The docs warn it's not ideal with a very large number of tenants.
Inject by abstraction, not by concrete class
TypeScript interfaces disappear at runtime, so they can't be injection tokens. An abstract class works well instead, because it exists at runtime and still describes the contract:
// src/users/users.repository.ts export interface User { id: string; email: string; displayName?: string | null; } export abstract class UsersRepository { abstract findById(id: string): Promise<User | null>; abstract findByEmail(email: string): Promise<User | null>; abstract insert(data: { email: string; displayName?: string }): Promise<User>; } DbUsersRepository is whatever implements that contract with your ORM. Here is the shape to fill in (the bodies are placeholders):
// src/users/db-users.repository.ts import { Injectable } from '@nestjs/common'; import { type User, UsersRepository } from './users.repository.js'; @Injectable() export class DbUsersRepository extends UsersRepository { // inject your ORM client here (Prisma, Drizzle, TypeORM, ...) async findById(id: string): Promise<User | null> { throw new Error('Implement with your ORM'); } async findByEmail(email: string): Promise<User | null> { throw new Error('Implement with your ORM'); } async insert(data: { email: string; displayName?: string }): Promise<User> { throw new Error('Implement with your ORM'); } } // src/users/users.module.ts import { Module } from '@nestjs/common'; import { DbUsersRepository } from './db-users.repository.js'; import { UsersController } from './users.controller.js'; import { UsersRepository } from './users.repository.js'; import { UsersService } from './users.service.js'; @Module({ controllers: [UsersController], providers: [ UsersService, { provide: UsersRepository, useClass: DbUsersRepository }, ], exports: [UsersService], }) export class UsersModule {} Your service doesn't know or care which database sits behind UsersRepository. And in tests you swap it for a fake in one line. We'll use that in the testing section.
6. Validate every request at the edge
Rule: never trust anything that comes in over the network. Bind the validation pipe globally (we did that in setup-app.ts) so no endpoint is left unprotected by accident.
The three options in that pipe do real work:
whitelist: truestrips any property that has no validation decorator.forbidNonWhitelisted: truegoes further and rejects the request instead of silently stripping.transform: trueturns plain payloads into DTO class instances and converts path and query values to the types you declared.
A DTO looks like this:
// src/users/dto/create-user.dto.ts import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsOptional() @IsString() @MaxLength(50) displayName?: string; } Four gotchas from the docs
Every property needs at least one decorator. With
whitelist: true, a property with no decorator gets stripped. If your field "disappears," this is usually why.Use concrete classes. TypeScript doesn't emit metadata for generics or interfaces, so the pipe can't validate them.
Don't use
import typefor DTO classes. Type-only imports are erased at runtime, and the pipe needs the real class. This rule is about classes. In the schema style below, the DTO is only a type and the schema is the real value, so import the schema normally and mark the type with the inlinetypemodifier.Arrays aren't validated by default.
@Body() dtos: CreateUserDto[]skips the elements. Wrap the array in a class, or useParseArrayPipe({ items: CreateUserDto }).
The new option in v12: schema validation
Nest 12 adds a second way to validate, built on the Standard Schema spec. That means Zod, Valibot, ArkType and others work out of the box. You pass the schema straight into the parameter decorator:
Zod version: These examples use the Zod 4 helpers like
z.email()andz.url(). On Zod 3, writez.string().email()andz.string().url()instead.
// src/users/dto/create-user.dto.ts // The schema-first version. Use this file instead of the class version above. import { z } from 'zod'; export const createUserSchema = z.object({ email: z.email(), displayName: z.string().trim().max(50).optional(), }); export type CreateUserDto = z.infer<typeof createUserSchema>; Now register the pipe. This step is not optional. The schema option only attaches the schema to the parameter. If you forget the pipe, the schema does nothing and the request goes through unchecked.
// src/setup-app.ts import { StandardSchemaValidationPipe, ValidationPipe } from '@nestjs/common'; // Both pipes can be registered together. ValidationPipe checks class-typed // parameters, and StandardSchemaValidationPipe checks parameters with a schema. app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), new StandardSchemaValidationPipe(), ); If a feature uses only schemas, the schema pipe alone is enough. Then use it in the controller:
// src/users/users.controller.ts import { createUserSchema, type CreateUserDto } from './dto/create-user.dto.js'; @Post() create(@Body({ schema: createUserSchema }) dto: CreateUserDto) { return this.usersService.create(dto); } The type comes from the schema, so you write the rules once and never keep a class and a type in sync. Coercion also works well for query strings:
export const listUsersQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().trim().optional(), }); @Get() findAll(@Query({ schema: listUsersQuerySchema }) query: z.infer<typeof listUsersQuerySchema>) { return this.usersService.findAll(query); } Notice the max(100) on limit. Always cap page sizes. An endpoint that lets a client ask for a million rows will eventually get that request.
One detail to remember: with Zod, z.object() strips unknown keys (like whitelist), and z.strictObject() rejects them (like forbidNonWhitelisted).
So which one should you pick?
| ValidationPipe + class-validator | StandardSchemaValidationPipe | |
|---|---|---|
| Where the rules live | Decorators on a DTO class | A schema object |
| Where the type comes from | The class itself | Inferred from the schema |
| Making variants (create vs update) | PartialType, PickType, OmitType | .partial(), .pick(), .omit() |
| Fits best | Existing code and class-based DTOs (the Swagger CLI plugin reads classes) | Teams that already like schema-first, shared schemas |
Choosing schemas doesn't mean giving up OpenAPI. Standard Schema schemas can feed OpenAPI generation too, so check the Swagger chapter for how to set that up.
The Nest team is clear that this is not a replacement. The docs still suggest class-validator as the default for most projects. According to the validation docs, both pipes can be registered at the same time. ValidationPipe only validates parameters typed with a class, and StandardSchemaValidationPipe only validates parameters that declare a schema. So you can adopt schemas one feature at a time. My opinion: pick one style per feature so your team doesn't have to switch mental models in the middle of a file.
Don't return your database objects
Whatever you validate on the way in, shape on the way out. Returning raw entities leaks columns like password hashes and internal flags. Use ClassSerializerInterceptor with class-transformer, or the new ...