Skip to content
NestJS projects 6 min read

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) {}

PartialType makes every field optional for PATCH, 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 synchronize outside development and manage schema changes with TypeORM migrations.
  • Always enable ValidationPipe with whitelist: true so 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 passwordHash from any endpoint.
  • Combine JwtAuthGuard for authentication with a dedicated RolesGuard for authorization rather than mixing the two concerns.
  • Document every endpoint with @ApiTags, @ApiBearerAuth, and DTO @ApiProperty so Swagger stays an accurate, living contract.
Last updated June 14, 2026
Was this helpful?