Tutorials
|stacknotice.com
16 min left|
0%
|3,200 words
Tutorials

NestJS Complete Guide: Enterprise Node.js APIs (2026)

NestJS brings Angular-style structure to Node.js: modules, dependency injection, decorators, guards, and pipes. Learn how to build production-grade APIs with TypeScript and NestJS.

C
Carlos Oliva
Software Developer
August 3, 202616 min read
Share:
NestJS Complete Guide: Enterprise Node.js APIs (2026)

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:dev

The 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 boilerplate

Enable 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/docs

Decorate 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

NestJSFastifyExpress
ArchitectureOpinionated (modules, DI)MinimalMinimal
TypeScriptFirst-classFirst-class@types/express
Validationclass-validator pipesJSON SchemaExternal
DI containerBuilt-inManualManual
Learning curveHighMediumLow
BoilerplateHighLowLow
SwaggerAuto-generatedManualManual
TestingClean (DI mockable)inject()supertest
Best forLarge teams, domain-heavy APIsHigh-throughput Node.jsSimple 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.

#nestjs#nodejs#typescript#api#backend
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.