NestJS is the most popular TypeScript framework for building server-side Node.js applications. It combines the simplicity of Express with powerful architecture patterns from Angular — modules, dependency injection, decorators — making it the go-to choice for production-grade APIs, microservices, and enterprise backends.
This tutorial takes you from zero to building a complete REST API with authentication, database integration, and real-world patterns.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Scaffold and run your first NestJS app |
| Modules | Organize code with NestJS module system |
| Controllers | Handle HTTP requests with decorators |
| Services | Write business logic with dependency injection |
| Pipes & Validation | Validate and transform incoming data |
| TypeORM | Connect to PostgreSQL and manage entities |
| Authentication | Implement JWT auth with Passport |
| Guards & Interceptors | Protect routes, transform responses |
| Testing | Write unit and e2e tests |
| Real projects | 2 complete production-ready projects |
Why NestJS?
| NestJS | Express | Fastify | Hono | Koa | |
|---|---|---|---|---|---|
| TypeScript | First-class | Optional | Optional | First-class | Optional |
| Architecture | Opinionated (modules/DI) | None | None | None | Middleware-only |
| Learning curve | Medium | Low | Low | Low | Low |
| Testability | Excellent (built-in DI) | Manual | Manual | Manual | Manual |
| Ecosystem | Large + Angular-inspired | Massive | Growing | Growing | Smaller |
| Best for | Enterprise, microservices, scalable APIs | Prototypes, simple APIs | High-perf APIs | Edge, tiny APIs | Middleware-heavy |
| CLI | Yes (nest CLI) | No | No | No | No |
NestJS is the right choice when you need:
- Structure at scale — the module system forces maintainable architecture
- TypeScript everywhere — end-to-end type safety out of the box
- Testability — dependency injection makes unit testing trivial
- Ecosystem — built-in support for WebSockets, gRPC, GraphQL, microservices, queues
Prerequisites
Before starting, you should know:
- JavaScript fundamentals (ES6+: classes, async/await, destructuring)
- Basic TypeScript (types, interfaces, decorators)
- Node.js basics (npm, package.json)
- HTTP fundamentals (GET/POST/PUT/DELETE, status codes)
1. Setup: Your First NestJS App
Install the CLI
npm install -g @nestjs/cli
Create a new project
nest new my-api
The CLI prompts for a package manager (npm/yarn/pnpm). Choose npm. It generates:
my-api/
├── src/
│ ├── app.controller.ts # Root controller
│ ├── app.controller.spec.ts # Controller tests
│ ├── app.module.ts # Root module
│ ├── app.service.ts # Root service
│ └── main.ts # Entry point (bootstrap)
├── test/
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── nest-cli.json
├── package.json
└── tsconfig.json
Run the app
cd my-api
npm run start:dev # Watch mode — restarts on file changes
Visit http://localhost:3000 — you'll see Hello World!.
main.ts — the bootstrap file
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
NestFactory.create() takes the root module and returns an application instance.
2. Core Building Blocks
NestJS has 4 core building blocks. Every application is composed of these:
| Building block | Decorator | Purpose |
|---|---|---|
| Module | @Module() |
Organizes related code into cohesive units |
| Controller | @Controller() |
Handles incoming HTTP requests |
| Service | @Injectable() |
Contains business logic, injected into controllers |
| Provider | @Injectable() |
Any class that can be injected (services, repositories, factories) |
3. Modules
Modules are the backbone of NestJS architecture. Every app has at least one module (AppModule). Large apps split into feature modules.
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [], // other modules this module depends on
controllers: [AppController], // controllers in this module
providers: [AppService], // services/providers in this module
exports: [], // what other modules can import from here
})
export class AppModule {}
Feature module example
nest generate module users # or: nest g mo users
nest generate controller users
nest generate service users
This creates:
src/
└── users/
├── users.module.ts
├── users.controller.ts
├── users.controller.spec.ts
├── users.service.ts
└── users.service.spec.ts
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // export so other modules can use UsersService
})
export class UsersModule {}
Then import it in AppModule:
// app.module.ts
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
// ...
})
export class AppModule {}
4. Controllers
Controllers handle HTTP requests. Each controller maps to a route prefix. Methods inside handle specific HTTP methods.
// users/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users') // prefix: /users
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get() // GET /users
findAll() {
return this.usersService.findAll();
}
@Get(':id') // GET /users/:id
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Post() // POST /users
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Put(':id') // PUT /users/:id
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id') // DELETE /users/:id
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
Common decorators
| Decorator | Purpose |
|---|---|
@Get(), @Post(), @Put(), @Delete(), @Patch() |
HTTP method handlers |
@Param('id') |
Route parameter (:id) |
@Body() |
Request body |
@Query('page') |
Query string parameter (?page=2) |
@Headers('authorization') |
Request header |
@Req() |
Raw Express/Fastify request object |
@Res() |
Raw response object (avoid — breaks interceptors) |
@HttpCode(201) |
Override default status code |
5. Services
Services hold business logic. They are @Injectable() — NestJS's DI system handles instantiation.
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private users: User[] = [];
private idCounter = 1;
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find(u => u.id === id);
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
create(dto: CreateUserDto): User {
const user: User = { id: this.idCounter++, ...dto };
this.users.push(user);
return user;
}
update(id: number, dto: UpdateUserDto): User {
const user = this.findOne(id);
Object.assign(user, dto);
return user;
}
remove(id: number): void {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) throw new NotFoundException(`User #${id} not found`);
this.users.splice(index, 1);
}
}
Dependency injection
NestJS uses constructor injection. The framework resolves dependencies automatically:
// If UsersController needs UsersService:
constructor(private readonly usersService: UsersService) {}
// If one service needs another:
// users.service.ts
constructor(private readonly emailService: EmailService) {}
The private readonly shorthand is idiomatic NestJS — it assigns the injected instance as a class property.
6. DTOs and Validation
Data Transfer Objects (DTOs) define the shape of request data. Combine them with class-validator for automatic validation.
npm install class-validator class-transformer
// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsOptional()
@IsString()
role?: string;
}
Enable the global ValidationPipe in main.ts:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip unknown properties
forbidNonWhitelisted: true, // throw if unknown properties sent
transform: true, // auto-transform types (string '1' → number 1)
}),
);
await app.listen(3000);
}
Now if a client sends invalid data, NestJS automatically returns:
{
"statusCode": 400,
"message": ["email must be an email", "name must be longer than or equal to 2 characters"],
"error": "Bad Request"
}
Common class-validator decorators
| Decorator | What it validates |
|---|---|
@IsString() |
Must be a string |
@IsNumber() |
Must be a number |
@IsEmail() |
Valid email format |
@IsUrl() |
Valid URL |
@IsBoolean() |
Must be boolean |
@IsArray() |
Must be an array |
@IsEnum(MyEnum) |
Must be enum value |
@MinLength(n) |
String min length |
@MaxLength(n) |
String max length |
@Min(n) / @Max(n) |
Number min/max |
@IsOptional() |
Field is not required |
@IsNotEmpty() |
Must not be empty string/array |
@ValidateNested() |
Validate nested objects |
7. Exception Handling
NestJS has built-in HTTP exceptions. Throw them from services or controllers:
import {
NotFoundException,
BadRequestException,
UnauthorizedException,
ForbiddenException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
// Throws 404
throw new NotFoundException('User not found');
// Throws 400
throw new BadRequestException('Invalid email format');
// Throws 401
throw new UnauthorizedException('Invalid credentials');
// Throws 403
throw new ForbiddenException('Admin access required');
// Throws 409
throw new ConflictException('Email already exists');
Custom exception filter
// common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message:
typeof exceptionResponse === 'object'
? (exceptionResponse as any).message
: exceptionResponse,
});
}
}
Apply globally:
// main.ts
app.useGlobalFilters(new HttpExceptionFilter());
8. Pipes
Pipes transform or validate data before it reaches a handler.
// Built-in pipes
import { ParseIntPipe, ParseUUIDPipe, DefaultValuePipe } from '@nestjs/common';
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// 'id' is guaranteed to be a number, not a string
return this.usersService.findOne(id);
}
@Get()
findAll(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number) {
return this.usersService.findAll(page);
}
Custom pipe
// common/pipes/trim.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class TrimPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [
k,
typeof v === 'string' ? v.trim() : v,
]),
);
}
return value;
}
}
9. Guards
Guards decide whether a request should proceed. Used for authentication and authorization.
// common/guards/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException();
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET,
});
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
Apply a guard:
// Apply to single route
@UseGuards(AuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user;
}
// Apply to entire controller
@UseGuards(AuthGuard)
@Controller('users')
export class UsersController {}
// Apply globally
app.useGlobalGuards(new AuthGuard(jwtService));
Role-based access with custom decorator
// common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// common/guards/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some(role => user.roles?.includes(role));
}
}
// Usage in controller
@Roles('admin')
@UseGuards(AuthGuard, RolesGuard)
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
10. Interceptors
Interceptors wrap request/response logic. Common uses: logging, response transformation, caching, timeout.
// common/interceptors/transform.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
data,
timestamp: new Date().toISOString(),
})),
);
}
}
// Apply globally in main.ts
app.useGlobalInterceptors(new TransformInterceptor());
// Now all responses become:
// { "data": { ... }, "timestamp": "2025-01-15T10:00:00.000Z" }
11. Database with TypeORM
TypeORM is the most common ORM choice for NestJS.
npm install @nestjs/typeorm typeorm pg
Connect to PostgreSQL
// app.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || 'myapp',
autoLoadEntities: true, // auto-detect entities registered with forFeature()
synchronize: true, // auto-create tables in dev (NEVER in production)
}),
UsersModule,
],
})
export class AppModule {}
Define an entity
// users/entities/user.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
BeforeInsert,
} from 'typeorm';
import * as bcrypt from 'bcrypt';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ select: false }) // never returned in queries by default
password: string;
@Column({ default: 'user' })
role: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@BeforeInsert()
async hashPassword() {
this.password = await bcrypt.hash(this.password, 10);
}
}
Register and inject repository
// users/users.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // register entity
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// users/users.service.ts
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.usersRepository.find();
}
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOneBy({ id });
if (!user) throw new NotFoundException(`User #${id} not found`);
return user;
}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository
.createQueryBuilder('user')
.addSelect('user.password') // bypass select: false
.where('user.email = :email', { email })
.getOne();
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.usersRepository.findOneBy({ email: dto.email });
if (existing) throw new ConflictException('Email already exists');
const user = this.usersRepository.create(dto);
return this.usersRepository.save(user);
}
async update(id: number, dto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
Object.assign(user, dto);
return this.usersRepository.save(user);
}
async remove(id: number): Promise<void> {
const user = await this.findOne(id);
await this.usersRepository.remove(user);
}
}
TypeORM column types
| Decorator | Description |
|---|---|
@PrimaryGeneratedColumn() |
Auto-increment integer PK |
@PrimaryGeneratedColumn('uuid') |
UUID PK |
@Column() |
Regular column (infers type from TS) |
@Column({ type: 'text' }) |
Explicit type |
@Column({ nullable: true }) |
Optional column |
@Column({ unique: true }) |
Unique constraint |
@Column({ default: 'user' }) |
Default value |
@CreateDateColumn() |
Auto-set on insert |
@UpdateDateColumn() |
Auto-set on update |
@DeleteDateColumn() |
Soft delete timestamp |
Relations
// one user has many posts
// user.entity.ts
@OneToMany(() => Post, post => post.author)
posts: Post[];
// post.entity.ts
@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: User;
// many-to-many (users ↔ roles)
@ManyToMany(() => Role)
@JoinTable()
roles: Role[];
// Loading relations in queries
const user = await this.usersRepository.findOne({
where: { id },
relations: { posts: true }, // eager load
});
// Or with QueryBuilder
const user = await this.usersRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.id = :id', { id })
.getOne();
12. Authentication with JWT
npm install @nestjs/passport @nestjs/jwt passport passport-jwt passport-local bcrypt
npm install -D @types/passport-jwt @types/passport-local @types/bcrypt
Auth module structure
nest g module auth
nest g controller auth
nest g service auth
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'super-secret',
signOptions: { expiresIn: '7d' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
// 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 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async signIn(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) throw new UnauthorizedException('Invalid credentials');
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) throw new UnauthorizedException('Invalid credentials');
const payload = { sub: user.id, email: user.email, role: user.role };
return {
access_token: await this.jwtService.signAsync(payload),
user: { id: user.id, name: user.name, email: user.email },
};
}
async register(dto: CreateUserDto) {
return this.usersService.create(dto);
}
}
// auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'super-secret',
});
}
async validate(payload: any) {
// returned value is attached to request.user
return { userId: payload.sub, email: payload.email, role: payload.role };
}
}
// auth/auth.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
export class SignInDto {
email: string;
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
register(@Body() dto: CreateUserDto) {
return this.authService.register(dto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
signIn(@Body() dto: SignInDto) {
return this.authService.signIn(dto.email, dto.password);
}
}
Use @UseGuards(AuthGuard('jwt')) or create a custom JwtAuthGuard:
// common/guards/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Usage
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
return req.user; // { userId, email, role }
}
13. Configuration
Never hardcode secrets. Use @nestjs/config:
npm install @nestjs/config
// app.module.ts
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // available in all modules without re-importing
envFilePath: '.env',
}),
// ...
],
})
export class AppModule {}
# .env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=secret
DATABASE_NAME=myapp
JWT_SECRET=your-very-long-random-secret
PORT=3000
// Inject ConfigService anywhere
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SomeService {
constructor(private configService: ConfigService) {}
doSomething() {
const dbHost = this.configService.get<string>('DATABASE_HOST');
const port = this.configService.get<number>('PORT', 3000); // with default
}
}
Typed config with validation
npm install joi
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
name: process.env.DATABASE_NAME,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d',
},
});
// Use in TypeOrmModule
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('database.host'),
port: config.get<number>('database.port'),
username: config.get('database.user'),
password: config.get('database.password'),
database: config.get('database.name'),
autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production',
}),
}),
14. Testing
NestJS has excellent testing support via Jest.
Unit tests
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Repository } from 'typeorm';
import { NotFoundException } from '@nestjs/common';
describe('UsersService', () => {
let service: UsersService;
let repository: jest.Mocked<Repository<User>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
find: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repository = module.get(getRepositoryToken(User));
});
describe('findOne', () => {
it('returns user when found', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' } as User;
repository.findOneBy.mockResolvedValue(mockUser);
const result = await service.findOne(1);
expect(result).toEqual(mockUser);
expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 });
});
it('throws NotFoundException when not found', async () => {
repository.findOneBy.mockResolvedValue(null);
await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('creates and saves a user', async () => {
const dto = { name: 'Bob', email: 'bob@example.com', password: 'secret123' };
const mockUser = { id: 2, ...dto } as User;
repository.findOneBy.mockResolvedValue(null); // no duplicate
repository.create.mockReturnValue(mockUser);
repository.save.mockResolvedValue(mockUser);
const result = await service.create(dto);
expect(result).toEqual(mockUser);
});
});
});
E2E tests
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
let jwtToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], // use real app with test database
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(() => app.close());
it('POST /auth/register — creates a user', async () => {
const response = await request(app.getHttpServer())
.post('/auth/register')
.send({ name: 'Alice', email: 'alice@test.com', password: 'password123' })
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.email).toBe('alice@test.com');
});
it('POST /auth/login — returns JWT', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'alice@test.com', password: 'password123' })
.expect(200);
expect(response.body).toHaveProperty('access_token');
jwtToken = response.body.access_token;
});
it('GET /users — requires auth', async () => {
await request(app.getHttpServer()).get('/users').expect(401);
});
it('GET /users — returns users with JWT', async () => {
const response = await request(app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${jwtToken}`)
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
15. Project 1: Blog API
Let's build a complete blog REST API.
Entities
// posts/entities/post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column({ type: 'text' })
content: string;
@Column({ default: false })
published: boolean;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
author: User;
@Column({ nullable: true })
authorId: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
DTOs
// posts/dto/create-post.dto.ts
import { IsString, IsBoolean, IsOptional, MinLength } from 'class-validator';
export class CreatePostDto {
@IsString()
@MinLength(5)
title: string;
@IsString()
@MinLength(20)
content: string;
@IsOptional()
@IsBoolean()
published?: boolean = false;
}
Service with pagination
// posts/posts.service.ts
@Injectable()
export class PostsService {
constructor(
@InjectRepository(Post) private postsRepo: Repository<Post>,
) {}
async findAll(page = 1, limit = 10) {
const [posts, total] = await this.postsRepo.findAndCount({
where: { published: true },
relations: { author: true },
select: { author: { id: true, name: true } },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { posts, total, page, lastPage: Math.ceil(total / limit) };
}
async create(dto: CreatePostDto, userId: number): Promise<Post> {
const post = this.postsRepo.create({ ...dto, authorId: userId });
return this.postsRepo.save(post);
}
async findOwn(userId: number): Promise<Post[]> {
return this.postsRepo.find({ where: { authorId: userId }, order: { createdAt: 'DESC' } });
}
}
Controller
// posts/posts.controller.ts
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController {
constructor(private postsService: PostsService) {}
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {
return this.postsService.findAll(page, limit);
}
@Post()
create(@Body() dto: CreatePostDto, @Request() req) {
return this.postsService.create(dto, req.user.userId);
}
@Get('mine')
findOwn(@Request() req) {
return this.postsService.findOwn(req.user.userId);
}
}
16. Project 2: Task Manager API
// tasks/entities/task.entity.ts
export enum TaskStatus {
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
DONE = 'DONE',
}
@Entity('tasks')
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'enum', enum: TaskStatus, default: TaskStatus.TODO })
status: TaskStatus;
@Column({ type: 'date', nullable: true })
dueDate: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
owner: User;
@Column()
ownerId: number;
@CreateDateColumn()
createdAt: Date;
}
// tasks/dto/create-task.dto.ts
import { IsString, IsOptional, IsEnum, IsDateString } from 'class-validator';
export class CreateTaskDto {
@IsString()
title: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsDateString()
dueDate?: string;
}
export class UpdateTaskStatusDto {
@IsEnum(TaskStatus)
status: TaskStatus;
}
// tasks/tasks.service.ts
@Injectable()
export class TasksService {
constructor(@InjectRepository(Task) private tasksRepo: Repository<Task>) {}
findByOwner(ownerId: number): Promise<Task[]> {
return this.tasksRepo.find({
where: { ownerId },
order: { createdAt: 'DESC' },
});
}
async create(dto: CreateTaskDto, ownerId: number): Promise<Task> {
const task = this.tasksRepo.create({ ...dto, ownerId });
return this.tasksRepo.save(task);
}
async updateStatus(id: string, status: TaskStatus, ownerId: number): Promise<Task> {
const task = await this.tasksRepo.findOne({ where: { id, ownerId } });
if (!task) throw new NotFoundException('Task not found');
task.status = status;
return this.tasksRepo.save(task);
}
async remove(id: string, ownerId: number): Promise<void> {
const task = await this.tasksRepo.findOne({ where: { id, ownerId } });
if (!task) throw new NotFoundException('Task not found');
await this.tasksRepo.remove(task);
}
}
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
synchronize: true in production |
Drops/alters tables on deploy | Set false, use migrations instead |
| Circular dependency between modules | Runtime DI error | Use forwardRef(() => Module) |
Not using transform: true in ValidationPipe |
Query params stay as strings | Enable transform: true |
Forgetting @Module exports |
Service not injectable in other modules | Export the service in its module |
Using @Res() directly |
Bypasses interceptors and exception filters | Return data from handler; only use @Res() for streaming |
| No global prefix on API routes | Conflicts with frontend SPA routes | app.setGlobalPrefix('api') |
| Returning passwords in responses | Security vulnerability | Add @Exclude() decorator + ClassSerializerInterceptor |
| Not validating env vars at startup | Silent failures in production | Use Joi schema validation in ConfigModule |
NestJS vs Express vs Fastify vs Hono vs Koa
| NestJS | Express | Fastify | Hono | Koa | |
|---|---|---|---|---|---|
| TypeScript | First-class | Manual setup | Optional | First-class | Optional |
| Architecture | Opinionated (modules/DI) | DIY | DIY | DIY | DIY |
| Performance | Fast | Medium | Fastest | Very fast | Fast |
| Learning curve | Medium-High | Low | Low | Low | Medium |
| Dependency injection | Built-in | Manual | Manual | Manual | Manual |
| CLI scaffolding | Yes | No | No | No | No |
| WebSockets | Built-in | socket.io | Manual | Manual | Manual |
| Microservices | Built-in transports | Manual | Manual | Manual | Manual |
| GraphQL | First-class integration | Manual | Manual | No | No |
| Enterprise adoption | Very high | Very high | Growing | Growing | Low |
| Bundle size | Large | Small | Small | Tiny | Small |
| Best for | Enterprise APIs, microservices, monorepos | Prototypes, simple APIs | High-throughput APIs | Edge/serverless, tiny | Middleware-heavy apps |
NestJS vs Related Concepts
| Term | What it means in NestJS context |
|---|---|
| Module | Unit of organization; imports/exports providers |
| Provider | Any @Injectable() class registered in a module |
| Guard | Authorization check — runs before the handler |
| Interceptor | Wraps request/response — for logging, transformation |
| Pipe | Validates/transforms request data before handler |
| Filter | Catches exceptions and formats error responses |
| Middleware | Express-style function; runs before guards |
| Decorator | TypeScript metadata — @Controller, @Get, etc. |
Frequently Asked Questions
What is NestJS used for? NestJS builds server-side Node.js applications: REST APIs, GraphQL APIs, WebSocket servers, gRPC services, and microservices. It's popular in enterprise settings where code organization and testability matter at scale.
Is NestJS better than Express? For small projects and prototypes, Express is faster to start. For larger teams and codebases, NestJS's enforced architecture (modules, DI) prevents the spaghetti code that unstructured Express apps often become. NestJS uses Express (or Fastify) under the hood.
Do I need to know Angular to learn NestJS? No. NestJS is inspired by Angular's module/DI patterns, but you don't need Angular experience. If you know TypeScript and Node.js basics, NestJS is approachable.
What database should I use with NestJS? TypeORM (PostgreSQL, MySQL, SQLite) and Prisma are the most popular choices. Mongoose is used for MongoDB. NestJS has first-party integrations for all of them.
How do I handle file uploads in NestJS?
Use @nestjs/platform-express with multer:
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(@UploadedFile() file: Express.Multer.File) {
return { filename: file.filename };
}
Is NestJS good for microservices? Yes — it's one of NestJS's strengths. It has built-in microservice transports (TCP, Redis, RabbitMQ, Kafka, gRPC, NATS) and a unified programming model for both HTTP and message-based services.