NestJS is the framework for backend TypeScript developers who want structure. Where Express and Fastify give you routing primitives and leave the architecture to you, NestJS prescribes a module system, dependency injection container, and decorator-based API that scales across large teams without turning into spaghetti.
It's the most downloaded TypeScript backend framework on npm by a wide margin, and for enterprise teams building APIs with many contributors, the opinionated structure is the point.
This guide covers NestJS from setup through production patterns: modules, dependency injection, guards, interceptors, pipes, and testing.
What NestJS Gives You
- Module system — features are encapsulated in modules, not scattered across files
- Dependency injection — services are injected, not imported directly, which makes testing clean
- Decorators —
@Controller,@Get,@Body,@UseGuards— declarative and readable - Pipes — validation and transformation at the route level with class-validator
- Guards — authentication and authorization as reusable interceptors
- Interceptors — logging, caching, response transformation
- Swagger auto-generation — your decorators become OpenAPI docs
The tradeoff: NestJS is verbose compared to Fastify or Hono. For a simple CRUD API, the boilerplate isn't worth it. For a team building a large service with clear domain boundaries, the structure pays off.
Installation
npm install -g @nestjs/cli
nest new my-api
cd my-api
npm run start:devThe CLI generates a complete project. Directory structure:
src/
├── app.module.ts # Root module
├── app.controller.ts # Root controller
├── app.service.ts # Root service
└── main.ts # Entry point
Core Building Blocks
Controllers
Controllers handle HTTP requests. Decorators define routes and extract request data:
// src/posts/posts.controller.ts
import {
Controller, Get, Post, Patch, Delete,
Param, Body, Query, HttpCode, HttpStatus,
ParseUUIDPipe
} from '@nestjs/common'
import { PostsService } from './posts.service'
import { CreatePostDto } from './dto/create-post.dto'
import { UpdatePostDto } from './dto/update-post.dto'
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
findAll(
@Query('page') page = 1,
@Query('limit') limit = 20,
@Query('search') search?: string
) {
return this.postsService.findAll({ page: +page, limit: +limit, search })
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.postsService.findOne(id)
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreatePostDto) {
return this.postsService.create(dto)
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePostDto
) {
return this.postsService.update(id, dto)
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.postsService.remove(id)
}
}Services (Providers)
Business logic lives in services, which are injected into controllers:
// src/posts/posts.service.ts
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { CreatePostDto } from './dto/create-post.dto'
import { UpdatePostDto } from './dto/update-post.dto'
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async findAll({ page, limit, search }: { page: number; limit: number; search?: string }) {
const where = search
? { published: true, title: { contains: search, mode: 'insensitive' as const } }
: { published: true }
const [posts, total] = await Promise.all([
this.prisma.post.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
select: { id: true, title: true, slug: true, excerpt: true, createdAt: true }
}),
this.prisma.post.count({ where })
])
return { posts, total, page, hasNextPage: page * limit < total }
}
async findOne(id: string) {
const post = await this.prisma.post.findUnique({ where: { id } })
if (!post) throw new NotFoundException(`Post ${id} not found`)
return post
}
async create(dto: CreatePostDto) {
const slug = dto.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')
return this.prisma.post.create({ data: { ...dto, slug } })
}
async update(id: string, dto: UpdatePostDto) {
await this.findOne(id) // throws NotFoundException if not found
return this.prisma.post.update({ where: { id }, data: dto })
}
async remove(id: string) {
await this.findOne(id)
await this.prisma.post.delete({ where: { id } })
}
}Modules
Modules group related functionality. Everything that belongs to a feature lives in one module:
// src/posts/posts.module.ts
import { Module } from '@nestjs/common'
import { PostsController } from './posts.controller'
import { PostsService } from './posts.service'
@Module({
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService] // export if other modules need PostsService
})
export class PostsModule {}// src/app.module.ts
import { Module } from '@nestjs/common'
import { PostsModule } from './posts/posts.module'
import { UsersModule } from './users/users.module'
import { AuthModule } from './auth/auth.module'
import { PrismaModule } from './prisma/prisma.module'
@Module({
imports: [
PrismaModule,
AuthModule,
UsersModule,
PostsModule,
]
})
export class AppModule {}Validation with DTOs
DTOs (Data Transfer Objects) define the shape of incoming requests. Use class-validator for declarative validation:
npm install class-validator class-transformer// src/posts/dto/create-post.dto.ts
import {
IsString, IsBoolean, IsOptional, MinLength, MaxLength, IsUrl
} from 'class-validator'
import { Transform } from 'class-transformer'
export class CreatePostDto {
@IsString()
@MinLength(3)
@MaxLength(200)
title: string
@IsString()
@MinLength(10)
content: string
@IsOptional()
@IsString()
@MaxLength(300)
excerpt?: string
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
published?: boolean
@IsOptional()
@IsUrl()
coverImage?: string
}// src/posts/dto/update-post.dto.ts
import { PartialType } from '@nestjs/mapped-types'
import { CreatePostDto } from './create-post.dto'
export class UpdatePostDto extends PartialType(CreatePostDto) {}
// All fields become optional — no boilerplateEnable validation globally in main.ts:
// src/main.ts
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import { AppModule } from './app.module'
async function bootstrap() {
const app = NestFactory.create(AppModule)
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties not in the DTO
forbidNonWhitelisted: true, // throw error for extra properties
transform: true, // auto-transform types (string → number)
transformOptions: {
enableImplicitConversion: true
}
}))
app.setGlobalPrefix('api')
app.enableCors({ origin: process.env.CORS_ORIGIN })
await app.listen(process.env.PORT ?? 3000)
}
bootstrap()Authentication with Guards
Guards return true (allow) or false (deny):
npm install @nestjs/jwt @nestjs/passport passport passport-jwt passport-local bcryptjs
npm install -D @types/passport-jwt @types/passport-local @types/bcryptjs// src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { UsersService } from '../users/users.service'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
ignoreExpiration: false
})
}
async validate(payload: { sub: string; email: string }) {
const user = await this.usersService.findOne(payload.sub)
if (!user) throw new UnauthorizedException()
return user // attached to request.user
}
}// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { UsersService } from '../users/users.service'
import * as bcrypt from 'bcryptjs'
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService
) {}
async login(email: string, password: string) {
const user = await this.usersService.findByEmail(email)
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials')
}
const payload = { sub: user.id, email: user.email, role: user.role }
return {
accessToken: this.jwtService.sign(payload),
user: { id: user.id, email: user.email, role: user.role }
}
}
}Use the guard on controllers or specific routes:
// Apply to all routes in the controller
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController { ... }
// Or to a specific route
@UseGuards(JwtAuthGuard)
@Delete(':id')
remove(@Param('id') id: string, @Request() req) {
return this.postsService.remove(id, req.user.id)
}Custom Decorators
Extract the authenticated user cleanly:
// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
return request.user
}
)
// Usage in controller
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: User) {
return user
}Interceptors — Logging and Transformation
// src/common/interceptors/logging.interceptor.ts
import {
Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger
} from '@nestjs/common'
import { Observable, tap } from 'rxjs'
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name)
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest()
const { method, url } = req
const start = Date.now()
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start
this.logger.log(`${method} ${url} — ${duration}ms`)
})
)
}
}// Transform response to a standard envelope
@Injectable()
export class ResponseTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString()
}))
)
}
}Apply globally:
app.useGlobalInterceptors(new LoggingInterceptor())Database with Prisma
Wrap Prisma in a module:
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect()
}
async onModuleDestroy() {
await this.$disconnect()
}
}// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global() // makes PrismaService available everywhere without importing PrismaModule
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}Swagger / OpenAPI
npm install @nestjs/swagger// src/main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'
const config = new DocumentBuilder()
.setTitle('My API')
.setDescription('API documentation')
.setVersion('1.0')
.addBearerAuth()
.build()
const document = SwaggerModule.createDocument(app, config)
SwaggerModule.setup('api/docs', app, document)
// Docs at http://localhost:3000/api/docsDecorate DTOs for richer documentation:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
export class CreatePostDto {
@ApiProperty({ description: 'Post title', example: 'Getting Started with NestJS' })
@IsString()
@MinLength(3)
title: string
@ApiPropertyOptional({ description: 'Whether to publish immediately', default: false })
@IsOptional()
@IsBoolean()
published?: boolean
}Testing
NestJS's DI makes testing clean — swap real services for mocks:
// src/posts/posts.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing'
import { PostsService } from './posts.service'
import { PrismaService } from '../prisma/prisma.service'
import { NotFoundException } from '@nestjs/common'
describe('PostsService', () => {
let service: PostsService
let prisma: jest.Mocked<PrismaService>
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PostsService,
{
provide: PrismaService,
useValue: {
post: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn()
}
}
}
]
}).compile()
service = module.get<PostsService>(PostsService)
prisma = module.get(PrismaService)
})
it('throws NotFoundException for missing post', async () => {
prisma.post.findUnique.mockResolvedValue(null)
await expect(service.findOne('non-existent')).rejects.toThrow(NotFoundException)
})
it('creates a post with a slug', async () => {
const mockPost = { id: '1', title: 'Hello World', slug: 'hello-world', content: '...', published: false, createdAt: new Date(), updatedAt: new Date() }
prisma.post.create.mockResolvedValue(mockPost)
const result = await service.create({ title: 'Hello World', content: '...' })
expect(prisma.post.create).toHaveBeenCalledWith({
data: expect.objectContaining({ slug: 'hello-world' })
})
expect(result.slug).toBe('hello-world')
})
})E2E test with supertest:
// test/posts.e2e-spec.ts
import { Test } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'
describe('Posts (e2e)', () => {
let app: INestApplication
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule]
}).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }))
await app.init()
})
afterAll(async () => await app.close())
it('GET /api/posts — returns paginated posts', async () => {
const res = await request(app.getHttpServer())
.get('/api/posts')
.expect(200)
expect(res.body).toHaveProperty('posts')
expect(res.body).toHaveProperty('total')
expect(Array.isArray(res.body.posts)).toBe(true)
})
it('POST /api/posts — returns 401 without token', async () => {
await request(app.getHttpServer())
.post('/api/posts')
.send({ title: 'Test', content: 'Content' })
.expect(401)
})
})NestJS vs Fastify vs Express
| NestJS | Fastify | Express | |
|---|---|---|---|
| Architecture | Opinionated (modules, DI) | Minimal | Minimal |
| TypeScript | First-class | First-class | @types/express |
| Validation | class-validator pipes | JSON Schema | External |
| DI container | Built-in | Manual | Manual |
| Learning curve | High | Medium | Low |
| Boilerplate | High | Low | Low |
| Swagger | Auto-generated | Manual | Manual |
| Testing | Clean (DI mockable) | inject() | supertest |
| Best for | Large teams, domain-heavy APIs | High-throughput Node.js | Simple apps, migration |
Choose NestJS when the team is large enough that consistent structure outweighs boilerplate cost, or when domain complexity benefits from clear module boundaries. Choose Fastify when throughput and low overhead matter more than structure.
NestJS's value is consistency at scale. The module system, DI, guards, and pipes all push the team toward the same patterns without code reviews enforcing architecture decisions manually. The nest g resource posts command generates controller, service, DTOs, and test stubs in one shot. For teams shipping fast with multiple developers on the same codebase, that consistency is the feature.