Project: REST API with Auth
This project walks through building a complete, production-grade REST API in NestJS: a simple “Articles” service backed by PostgreSQL. You will model entities with TypeORM, expose CRUD endpoints through controllers and services, validate every incoming payload with DTOs, lock the API down with JWT authentication and role-based access control (RBAC), and finally document and explore it with Swagger. The result is a structure you can copy into any real backend.
Scaffolding the project
Start from the Nest CLI and install the runtime dependencies. Using the CLI gives you a tested module layout and strict TypeScript out of the box.
npm i -g @nestjs/cli
nest new articles-api
cd articles-api
# Persistence, auth, validation, docs
npm i @nestjs/typeorm typeorm pg
npm i @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm i class-validator class-transformer @nestjs/swagger
npm i -D @types/passport-jwt @types/bcrypt
Wire global validation and the database connection in the root module. Enabling whitelist strips unknown properties, and transform coerces payloads into your DTO classes.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticlesModule } from './articles/articles.module';
import { AuthModule } from './auth/auth.module';
import { User } from './auth/user.entity';
import { Article } from './articles/article.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL ?? 'postgres://dev:dev@localhost:5432/articles',
entities: [User, Article],
synchronize: true, // dev only — use migrations in production
}),
AuthModule,
ArticlesModule,
],
})
export class AppModule {}
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const config = new DocumentBuilder()
.setTitle('Articles API')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config));
await app.listen(3000);
}
bootstrap();
Modeling the data
TypeORM entities map classes to tables. Each Article belongs to an author, and User carries a role used by RBAC.
// src/auth/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
export enum Role {
USER = 'user',
ADMIN = 'admin',
}
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid') id: string;
@Column({ unique: true }) email: string;
@Column() passwordHash: string;
@Column({ type: 'enum', enum: Role, default: Role.USER }) role: Role;
}
// src/articles/article.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Article {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() title: string;
@Column('text') body: string;
@Column({ default: false }) published: boolean;
@Column() authorId: string;
@CreateDateColumn() createdAt: Date;
}
DTOs and validation
DTOs define the public shape of requests. The class-validator decorators are enforced automatically by the global ValidationPipe, and @ApiProperty feeds Swagger.
// src/articles/dto/create-article.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateArticleDto {
@ApiProperty() @IsString() @MinLength(3) title: string;
@ApiProperty() @IsString() @MinLength(10) body: string;
@ApiProperty({ required: false }) @IsOptional() @IsBoolean() published?: boolean;
}
// src/articles/dto/update-article.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateArticleDto } from './create-article.dto';
export class UpdateArticleDto extends PartialType(CreateArticleDto) {}
PartialTypemakes every field optional forPATCH, so you reuse the same validation rules without duplicating decorators.
Service and controller
The service owns persistence and business rules; the controller maps HTTP verbs to service methods. Injecting the repository keeps the service testable.
// src/articles/articles.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './article.entity';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
@Injectable()
export class ArticlesService {
constructor(@InjectRepository(Article) private repo: Repository<Article>) {}
create(dto: CreateArticleDto, authorId: string) {
return this.repo.save(this.repo.create({ ...dto, authorId }));
}
findAll() {
return this.repo.find({ order: { createdAt: 'DESC' } });
}
async findOne(id: string) {
const article = await this.repo.findOneBy({ id });
if (!article) throw new NotFoundException(`Article ${id} not found`);
return article;
}
async update(id: string, dto: UpdateArticleDto) {
const article = await this.findOne(id);
return this.repo.save({ ...article, ...dto });
}
async remove(id: string) {
await this.findOne(id);
await this.repo.delete(id);
}
}
// src/articles/articles.controller.ts
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { Role } from '../auth/user.entity';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
@ApiTags('articles')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('articles')
export class ArticlesController {
constructor(private readonly articles: ArticlesService) {}
@Post()
create(@Body() dto: CreateArticleDto, @Req() req) {
return this.articles.create(dto, req.user.userId);
}
@Get() findAll() { return this.articles.findAll(); }
@Get(':id') findOne(@Param('id') id: string) { return this.articles.findOne(id); }
@Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateArticleDto) { return this.articles.update(id, dto); }
@Roles(Role.ADMIN)
@Delete(':id') remove(@Param('id') id: string) { return this.articles.remove(id); }
}
Securing with JWT and RBAC
Authentication verifies a bearer token; the Passport JWT strategy decodes it and attaches req.user. A small Roles decorator plus a guard enforce who can call sensitive endpoints — here only admins can delete.
// src/auth/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(),
secretOrKey: process.env.JWT_SECRET ?? 'dev-secret',
});
}
validate(payload: { sub: string; role: string }) {
return { userId: payload.sub, role: payload.role };
}
}
// src/auth/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from './user.entity';
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
// src/auth/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './user.entity';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get<Role[]>('roles', ctx.getHandler());
if (!required) return true;
const { user } = ctx.switchToHttp().getRequest();
return required.includes(user.role);
}
}
The AuthService issues tokens on login after verifying the password hash with bcrypt:
// src/auth/auth.service.ts (excerpt)
async login(email: string, password: string) {
const user = await this.users.findOneBy({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash)))
throw new UnauthorizedException('Invalid credentials');
const token = await this.jwt.signAsync({ sub: user.id, role: user.role });
return { access_token: token };
}
Running and testing
Start the server, then drive it with curl. Protected routes reject anonymous requests.
npm run start:dev
curl -s localhost:3000/articles
Output:
{"statusCode":401,"message":"Unauthorized"}
A unit test confirms the service throws on a missing record, using a mocked repository so no database is required:
// src/articles/articles.service.spec.ts
it('throws when article is missing', async () => {
const repo = { findOneBy: jest.fn().mockResolvedValue(null) } as any;
const service = new ArticlesService(repo);
await expect(service.findOne('nope')).rejects.toThrow('Article nope not found');
});
Browse the live, interactive contract at http://localhost:3000/docs, where Swagger renders every DTO and lets you authorize with a bearer token.
Best Practices
- Keep controllers thin: routing, guards, and serialization only. Put all logic in services.
- Disable
synchronizeoutside development and manage schema changes with TypeORM migrations. - Always enable
ValidationPipewithwhitelist: trueso clients cannot inject unexpected fields. - Store the JWT secret and database URL in environment variables, never in source.
- Hash passwords with bcrypt and never return
passwordHashfrom any endpoint. - Combine
JwtAuthGuardfor authentication with a dedicatedRolesGuardfor authorization rather than mixing the two concerns. - Document every endpoint with
@ApiTags,@ApiBearerAuth, and DTO@ApiPropertyso Swagger stays an accurate, living contract.