NestJS + PostgreSQL: Create Your First REST API

CRUD example with TypeORM

Building a REST API with NestJS, PostgreSQL, and TypeORM is a powerful, scalable choice for production apps. In this guide, you’ll create a complete CRUD API (Create, Read, Update, Delete) using a simple Todo resource.

We’ll cover project setup, database configuration, an entity + DTOs, a service, a controller, and how to test with cURL.


Prerequisites

  • Node.js 18+
  • PostgreSQL 13+ running locally (with a database created)
  • npm or pnpm
  • Nest CLI:
npm i -g @nestjs/cli

1) Create the Project

nest new nest-pg-crud
# Choose npm or pnpm
cd nest-pg-crud

Install required packages:

npm i @nestjs/typeorm typeorm pg class-validator class-transformer dotenv

2) Environment Variables

Create a .env file in the project root:

# .env
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=nest_crud

Ensure the database nest_crud exists:

CREATE DATABASE nest_crud;

3) TypeORM + PostgreSQL Setup

Open src/app.module.ts and configure TypeORM (v0.3+ style):

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TodoModule } from './todo/todo.module';
import { Todo } from './todo/todo.entity';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      useFactory: () => ({
        type: 'postgres',
        host: process.env.DB_HOST,
        port: Number(process.env.DB_PORT),
        username: process.env.DB_USER,
        password: process.env.DB_PASS,
        database: process.env.DB_NAME,
        entities: [Todo],
        synchronize: true, // turn off in production; use migrations instead
        logging: false,
      }),
    }),
    TodoModule,
  ],
})
export class AppModule {}

4) Create the Todo Module

Generate files:

nest g module todo
nest g service todo
nest g controller todo

5) Define the Entity

// src/todo/todo.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

@Entity()
export class Todo {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 120 })
  title: string;

  @Column({ type: 'text', nullable: true })
  description?: string;

  @Column({ default: false })
  completed: boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Register the entity in the module:

// src/todo/todo.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Todo } from './todo.entity';
import { TodoService } from './todo.service';
import { TodoController } from './todo.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Todo])],
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoModule {}

6) Create DTOs (Validation)

// src/todo/dto/create-todo.dto.ts
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';

export class CreateTodoDto {
  @IsString()
  @MaxLength(120)
  title: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsBoolean()
  completed?: boolean;
}
// src/todo/dto/update-todo.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateTodoDto } from './create-todo.dto';

export class UpdateTodoDto extends PartialType(CreateTodoDto) {}

Enable global validation pipe in main.ts:

// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
  await app.listen(process.env.PORT || 3000);
}
bootstrap();

Install @nestjs/mapped-types (for PartialType) if not present:

npm i @nestjs/mapped-types

7) Service (Business Logic)

// src/todo/todo.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { Todo } from './todo.entity';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Injectable()
export class TodoService {
  constructor(
    @InjectRepository(Todo)
    private readonly repo: Repository<Todo>,
  ) {}

  async create(dto: CreateTodoDto): Promise<Todo> {
    const todo = this.repo.create(dto);
    return this.repo.save(todo);
  }

  async findAll(q?: string, page = 1, limit = 10): Promise<{ data: Todo[]; total: number; page: number; limit: number }> {
    const where: FindOptionsWhere<Todo>[] = q
      ? [{ title: ILike(`%${q}%`) }, { description: ILike(`%${q}%`) }]
      : [{}];

    const [data, total] = await this.repo.findAndCount({
      where,
      order: { createdAt: 'DESC' },
      skip: (page - 1) * limit,
      take: limit,
    });

    return { data, total, page, limit };
  }

  async findOne(id: string): Promise<Todo> {
    const todo = await this.repo.findOne({ where: { id } });
    if (!todo) throw new NotFoundException('Todo not found');
    return todo;
  }

  async update(id: string, dto: UpdateTodoDto): Promise<Todo> {
    const todo = await this.findOne(id);
    Object.assign(todo, dto);
    return this.repo.save(todo);
  }

  async remove(id: string): Promise<void> {
    const todo = await this.findOne(id);
    await this.repo.remove(todo);
  }
}

8) Controller (Routes)

// src/todo/todo.controller.ts
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { TodoService } from './todo.service';
import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';

@Controller('todos')
export class TodoController {
  constructor(private readonly service: TodoService) {}

  @Post()
  create(@Body() dto: CreateTodoDto) {
    return this.service.create(dto);
  }

  @Get()
  findAll(
    @Query('q') q?: string,
    @Query('page') page = '1',
    @Query('limit') limit = '10',
  ) {
    return this.service.findAll(q, Number(page), Number(limit));
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.service.findOne(id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() dto: UpdateTodoDto) {
    return this.service.update(id, dto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.service.remove(id);
  }
}

9) Start the Server

npm run start:dev
# API runs at http://localhost:3000

10) Test the Endpoints (cURL)

Create

curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn NestJS","description":"Build a CRUD API","completed":false}'

List (with pagination + search)

curl "http://localhost:3000/todos?page=1&limit=5&q=nest"

Get by ID

curl http://localhost:3000/todos/<UUID>

Update

curl -X PATCH http://localhost:3000/todos/<UUID> \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

Delete

curl -X DELETE http://localhost:3000/todos/<UUID>

Production Tips

  • Turn off synchronize and use migrations for schema changes.
  • Add a global exception filter and logging middleware.
  • Secure env vars with a proper config and secrets management.
  • Dockerize Postgres + API for consistent environments.

Wrap-Up

You now have a fully working NestJS + PostgreSQL REST API with TypeORM, DTO validation, pagination, and search. This structure scales well for real projects—just add modules for new domains and keep business logic inside services.


Optional: Package Scripts

// package.json (partial)
{
  "scripts": {
    "start": "nest start",
    "start:dev": "nest start --watch",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\"",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
  }
}

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *