Building Production REST APIs with NestJS, TypeORM, and PostgreSQL

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

NestJS is the most popular enterprise-grade Node.js framework — and for good reason. Built with TypeScript, inspired by Angular's architecture, and powered by Express (or Fastify) under the hood, NestJS brings structure, testability, and scalability to backend development. Combined with TypeORM and PostgreSQL, it forms a production-ready stack used by companies like Adidas, Roche, and Autodesk.

What You'll Build

A complete User Management API with:

  • Modular NestJS architecture with controllers, services, and repositories
  • PostgreSQL database with TypeORM entities and migrations
  • JWT-based authentication (register, login, protected routes)
  • Request validation with class-validator and DTOs
  • Structured error handling with exception filters
  • Pagination, filtering, and sorting
  • Environment-based configuration with @nestjs/config
  • Unit and e2e tests

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • PostgreSQL 15+ running locally or via Docker
  • npm or yarn package manager
  • Basic understanding of TypeScript and REST concepts
  • A code editor (VS Code recommended)

New to TypeScript? You should be comfortable with classes, decorators, interfaces, and generics. The official TypeScript Handbook is a great starting point.


Why NestJS?

Before diving in, let's understand what makes NestJS stand out:

FeatureNestJSExpressFastifyHono
ArchitectureOpinionated (modules)MinimalMinimalMinimal
TypeScriptFirst-classAdd-onAdd-onFirst-class
DI ContainerBuilt-inNoneNoneNone
CLIFull scaffoldingNoneNoneNone
TestingBuilt-in utilitiesManualManualManual
Learning CurveModerateLowLowLow

NestJS trades the flexibility of Express for a structured, opinionated approach that pays off as your project scales. If you've worked with Angular or Spring Boot, the patterns will feel familiar.


Step 1: Project Setup

Install the NestJS CLI globally and create a new project:

npm i -g @nestjs/cli
nest new user-api

Choose npm when prompted. Then navigate into the project:

cd user-api

Install the required dependencies:

npm install @nestjs/typeorm typeorm pg
npm install @nestjs/config
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install class-validator class-transformer
npm install bcrypt
npm install -D @types/passport-jwt @types/bcrypt

Your project structure looks like this:

user-api/
├── src/
│   ├── app.module.ts       # Root module
│   ├── app.controller.ts   # Root controller
│   ├── app.service.ts      # Root service
│   └── main.ts             # Entry point
├── test/
│   └── app.e2e-spec.ts     # E2E tests
├── nest-cli.json
├── tsconfig.json
└── package.json

Step 2: Database Configuration

Create a .env file in the project root:

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
DATABASE_NAME=user_api
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=1h

Update app.module.ts to configure TypeORM and environment variables:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
 
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    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: process.env.NODE_ENV !== 'production',
      }),
    }),
  ],
})
export class AppModule {}

Never use synchronize: true in production. It automatically alters your database schema on every app start, which can cause data loss. Use migrations instead — we'll set those up later.


Step 3: Create the User Entity

Generate the users module using the CLI:

nest g module users
nest g controller users
nest g service users

Create the User entity:

// src/users/entities/user.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
 
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;
 
  @Column({ length: 100 })
  name: string;
 
  @Column({ unique: true })
  email: string;
 
  @Column({ select: false })
  password: string;
 
  @Column({ default: 'user' })
  role: string;
 
  @Column({ default: true })
  isActive: boolean;
 
  @CreateDateColumn()
  createdAt: Date;
 
  @UpdateDateColumn()
  updatedAt: Date;
}

Register the entity in the users module:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
 
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Step 4: DTOs and Validation

Create Data Transfer Objects to validate incoming requests:

// src/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;
}
// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
 
export class UpdateUserDto extends PartialType(CreateUserDto) {}

Create a query DTO for pagination and filtering:

// src/users/dto/query-user.dto.ts
import { IsOptional, IsInt, Min, IsString } from 'class-validator';
import { Type } from 'class-transformer';
 
export class QueryUserDto {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;
 
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  limit?: number = 10;
 
  @IsOptional()
  @IsString()
  search?: string;
 
  @IsOptional()
  @IsString()
  role?: string;
}

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 = await NestFactory.create(AppModule);
 
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );
 
  app.enableCors();
  app.setGlobalPrefix('api/v1');
 
  await app.listen(3000);
  console.log(`Application running on: ${await app.getUrl()}`);
}
bootstrap();

Step 5: Users Service (Business Logic)

Implement the service with CRUD operations, pagination, and password hashing:

// src/users/users.service.ts
import {
  Injectable,
  NotFoundException,
  ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
 
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}
 
  async create(createUserDto: CreateUserDto): Promise<User> {
    const existing = await this.usersRepository.findOne({
      where: { email: createUserDto.email },
    });
 
    if (existing) {
      throw new ConflictException('Email already registered');
    }
 
    const hashedPassword = await bcrypt.hash(createUserDto.password, 12);
 
    const user = this.usersRepository.create({
      ...createUserDto,
      password: hashedPassword,
    });
 
    const saved = await this.usersRepository.save(user);
    delete saved.password;
    return saved;
  }
 
  async findAll(query: QueryUserDto) {
    const { page, limit, search, role } = query;
    const skip = (page - 1) * limit;
 
    const where: any = {};
    if (role) where.role = role;
    if (search) where.name = Like(`%${search}%`);
 
    const [users, total] = await this.usersRepository.findAndCount({
      where,
      skip,
      take: limit,
      order: { createdAt: 'DESC' },
    });
 
    return {
      data: users,
      meta: {
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
      },
    };
  }
 
  async findOne(id: string): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User with ID "${id}" not found`);
    }
    return user;
  }
 
  async findByEmail(email: string): Promise<User> {
    return this.usersRepository.findOne({
      where: { email },
      select: ['id', 'name', 'email', 'password', 'role', 'isActive'],
    });
  }
 
  async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id);
 
    if (updateUserDto.password) {
      updateUserDto.password = await bcrypt.hash(updateUserDto.password, 12);
    }
 
    Object.assign(user, updateUserDto);
    return this.usersRepository.save(user);
  }
 
  async remove(id: string): Promise<void> {
    const user = await this.findOne(id);
    await this.usersRepository.remove(user);
  }
}

Step 6: Users Controller (HTTP Layer)

Create the controller with proper HTTP methods and status codes:

// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
 
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
 
  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
 
  @Get()
  findAll(@Query() query: QueryUserDto) {
    return this.usersService.findAll(query);
  }
 
  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.findOne(id);
  }
 
  @Put(':id')
  update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }
 
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.remove(id);
  }
}

Step 7: Authentication with JWT

Generate the auth module:

nest g module auth
nest g controller auth
nest g service auth

Create the JWT strategy:

// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
 
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
    private usersService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }
 
  async validate(payload: { sub: string; email: string }) {
    const user = await this.usersService.findOne(payload.sub);
    if (!user || !user.isActive) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

Implement the auth service:

// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { CreateUserDto } from '../users/dto/create-user.dto';
 
@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}
 
  async register(createUserDto: CreateUserDto) {
    const user = await this.usersService.create(createUserDto);
    const token = this.generateToken(user.id, user.email);
    return { user, access_token: token };
  }
 
  async login(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);
 
    if (!user || !(await bcrypt.compare(password, user.password))) {
      throw new UnauthorizedException('Invalid credentials');
    }
 
    delete user.password;
    const token = this.generateToken(user.id, user.email);
    return { user, access_token: token };
  }
 
  private generateToken(userId: string, email: string): string {
    return this.jwtService.sign({ sub: userId, email });
  }
}

Create the auth controller:

// src/auth/auth.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateUserDto } from '../users/dto/create-user.dto';
 
class LoginDto {
  email: string;
  password: string;
}
 
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}
 
  @Post('register')
  register(@Body() createUserDto: CreateUserDto) {
    return this.authService.register(createUserDto);
  }
 
  @Post('login')
  @HttpCode(HttpStatus.OK)
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto.email, loginDto.password);
  }
}

Wire up the auth module:

// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
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.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get('JWT_SECRET'),
        signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '1h') },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Step 8: Protecting Routes with Guards

Create a JWT auth guard:

// src/auth/guards/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
 
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Create a roles guard for authorization:

// src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
 
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) =>
  Reflect.metadata(ROLES_KEY, roles);
 
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
 
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );
 
    if (!requiredRoles) return true;
 
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user.role);
  }
}

Now protect the users controller:

// src/users/users.controller.ts (updated)
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
  UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard, Roles } from '../auth/guards/roles.guard';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUserDto } from './dto/query-user.dto';
 
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
 
  @Get()
  findAll(@Query() query: QueryUserDto) {
    return this.usersService.findAll(query);
  }
 
  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.findOne(id);
  }
 
  @Put(':id')
  update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }
 
  @Delete(':id')
  @Roles('admin')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.remove(id);
  }
}

Step 9: Exception Filters and Error Handling

Create a global exception filter for consistent error responses:

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
 
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
 
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
 
    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';
 
    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      ...(typeof message === 'string' ? { message } : message),
    });
  }
}

Register it globally in main.ts:

// Add to main.ts bootstrap function
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
 
app.useGlobalFilters(new AllExceptionsFilter());

Step 10: Database Migrations

Configure TypeORM CLI for migrations. Create typeorm.config.ts at the project root:

// typeorm.config.ts
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';
 
dotenv.config();
 
export default new DataSource({
  type: 'postgres',
  host: process.env.DATABASE_HOST,
  port: parseInt(process.env.DATABASE_PORT, 10),
  username: process.env.DATABASE_USER,
  password: process.env.DATABASE_PASSWORD,
  database: process.env.DATABASE_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
});

Add migration scripts to package.json:

{
  "scripts": {
    "migration:generate": "typeorm migration:generate -d typeorm.config.ts",
    "migration:run": "typeorm migration:run -d typeorm.config.ts",
    "migration:revert": "typeorm migration:revert -d typeorm.config.ts"
  }
}

Generate and run your first migration:

npx ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm migration:generate src/migrations/CreateUsers -d typeorm.config.ts
 
npx ts-node -r tsconfig-paths/register ./node_modules/.bin/typeorm migration:run -d typeorm.config.ts

Step 11: Testing

NestJS includes excellent testing utilities. Write a unit test for the users service:

// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { NotFoundException } from '@nestjs/common';
 
const mockUser: Partial<User> = {
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: 'John Doe',
  email: 'john@example.com',
  role: 'user',
  isActive: true,
};
 
describe('UsersService', () => {
  let service: UsersService;
  let repository: Repository<User>;
 
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            findOne: jest.fn(),
            findAndCount: 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('should return a user by ID', async () => {
      jest.spyOn(repository, 'findOne').mockResolvedValue(mockUser as User);
 
      const result = await service.findOne(mockUser.id);
      expect(result).toEqual(mockUser);
    });
 
    it('should throw NotFoundException for invalid ID', async () => {
      jest.spyOn(repository, 'findOne').mockResolvedValue(null);
 
      await expect(service.findOne('invalid-id')).rejects.toThrow(
        NotFoundException,
      );
    });
  });
 
  describe('findAll', () => {
    it('should return paginated users', async () => {
      jest
        .spyOn(repository, 'findAndCount')
        .mockResolvedValue([[mockUser as User], 1]);
 
      const result = await service.findAll({ page: 1, limit: 10 });
      expect(result.data).toHaveLength(1);
      expect(result.meta.total).toBe(1);
    });
  });
});

Write an e2e test:

// 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 authToken: string;
 
  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
 
    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    app.setGlobalPrefix('api/v1');
    await app.init();
 
    // Register and get token
    const res = await request(app.getHttpServer())
      .post('/api/v1/auth/register')
      .send({
        name: 'Test User',
        email: 'test@example.com',
        password: 'password123',
      });
 
    authToken = res.body.access_token;
  });
 
  it('/api/v1/users (GET) - should require auth', () => {
    return request(app.getHttpServer())
      .get('/api/v1/users')
      .expect(401);
  });
 
  it('/api/v1/users (GET) - should return users with auth', () => {
    return request(app.getHttpServer())
      .get('/api/v1/users')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200)
      .expect((res) => {
        expect(res.body.data).toBeDefined();
        expect(res.body.meta).toBeDefined();
      });
  });
 
  afterAll(async () => {
    await app.close();
  });
});

Run your tests:

# Unit tests
npm run test
 
# E2E tests
npm run test:e2e
 
# Test coverage
npm run test:cov

Step 12: Final Project Structure

Your completed project should look like this:

user-api/
├── src/
│   ├── auth/
│   │   ├── guards/
│   │   │   ├── jwt-auth.guard.ts
│   │   │   └── roles.guard.ts
│   │   ├── strategies/
│   │   │   └── jwt.strategy.ts
│   │   ├── auth.controller.ts
│   │   ├── auth.module.ts
│   │   └── auth.service.ts
│   ├── common/
│   │   └── filters/
│   │       └── http-exception.filter.ts
│   ├── migrations/
│   │   └── ...CreateUsers.ts
│   ├── users/
│   │   ├── dto/
│   │   │   ├── create-user.dto.ts
│   │   │   ├── update-user.dto.ts
│   │   │   └── query-user.dto.ts
│   │   ├── entities/
│   │   │   └── user.entity.ts
│   │   ├── users.controller.ts
│   │   ├── users.module.ts
│   │   ├── users.service.ts
│   │   └── users.service.spec.ts
│   ├── app.module.ts
│   └── main.ts
├── test/
│   └── users.e2e-spec.ts
├── .env
├── typeorm.config.ts
└── package.json

Testing Your API

Start the application:

npm run start:dev

Test the endpoints using curl:

# Register a new user
curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "alice@example.com", "password": "securepass123"}'
 
# Login
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "securepass123"}'
 
# Get all users (with token)
curl http://localhost:3000/api/v1/users \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
 
# Get users with pagination
curl "http://localhost:3000/api/v1/users?page=1&limit=5&search=alice" \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

Troubleshooting

"Cannot find module 'pg'" — Install the PostgreSQL driver: npm install pg

"relation users does not exist" — Run migrations or set synchronize: true temporarily during development.

"Unauthorized" on all routes — Verify your JWT_SECRET matches between .env and token generation. Check that the token hasn't expired.

Validation not working — Ensure ValidationPipe is registered globally in main.ts and that class-validator decorators are on your DTO properties.

Circular dependency errors — Use forwardRef() when two modules depend on each other: @Inject(forwardRef(() => UsersService)).


Next Steps

Now that you have a solid foundation, consider extending your API with:

  • Swagger documentation — Add @nestjs/swagger for auto-generated API docs
  • Rate limiting — Use @nestjs/throttler to protect against abuse
  • Caching — Implement Redis caching with @nestjs/cache-manager
  • File uploads — Add @nestjs/platform-express with Multer for file handling
  • WebSockets — Add real-time features with @nestjs/websockets
  • GraphQL — Replace REST with @nestjs/graphql and Apollo Server
  • Microservices — Split into microservices using @nestjs/microservices

Conclusion

You've built a production-ready REST API with NestJS, TypeORM, and PostgreSQL. You've learned how NestJS's modular architecture promotes clean code organization, how TypeORM simplifies database operations, and how to implement JWT authentication with role-based access control.

NestJS's opinionated structure might feel heavyweight for small projects, but it shines as your application grows — the dependency injection system, built-in testing utilities, and modular design make it straightforward to add features without accruing technical debt. This is why it remains the go-to choice for enterprise Node.js applications.


Want to read more tutorials? Check out our latest tutorial on AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Building REST APIs with Go and Fiber: A Practical Beginner's Guide

Learn how to build fast, production-ready REST APIs using Go and the Fiber web framework. This step-by-step guide covers project setup, routing, JSON handling, database integration with GORM, middleware, error handling, and testing — from zero to a working API.

30 min read·