Blog/Tutorials & Step-by-Step/Build a REST API with NestJS and PostgreSQL
POST
May 01, 2025
LAST UPDATEDMay 01, 2025

Build a REST API with NestJS and PostgreSQL

A complete tutorial on building a production-ready REST API with NestJS, PostgreSQL, and Drizzle ORM — covering CRUD, validation, error handling, and testing.

Tags

NestJSPostgreSQLREST APIBackend
Build a REST API with NestJS and PostgreSQL
6 min read

Build a REST API with NestJS and PostgreSQL

In this tutorial, you will build a complete REST API using NestJS, PostgreSQL, and Drizzle ORM. The API manages a collection of articles with full CRUD operations, request validation, structured error handling, and automated tests. By the end, you will have a production-ready backend that follows NestJS best practices for modularity, type safety, and testability.

TL;DR

Set up a NestJS project with Drizzle ORM connected to PostgreSQL. Define your schema in TypeScript, generate migrations, and build CRUD endpoints with DTO validation using class-validator. Add global exception filters for consistent error responses and write unit tests for your services and controllers.

Prerequisites

  • Node.js 18 or later
  • PostgreSQL installed locally or a cloud-hosted instance (Neon, Supabase, or Railway)
  • Basic understanding of TypeScript decorators and REST API concepts
  • A tool for testing APIs like Postman, Insomnia, or curl

Step 1: Initialize the NestJS Project

Use the NestJS CLI to scaffold a new project:

bash
npm i -g @nestjs/cli
nest new articles-api
cd articles-api

Choose npm or yarn when prompted. The CLI generates a project with a modular structure: app.module.ts as the root module, app.controller.ts for routes, and app.service.ts for business logic.

Install the dependencies for Drizzle ORM and PostgreSQL:

bash
npm install drizzle-orm postgres
npm install -D drizzle-kit @types/node

The postgres package is the PostgreSQL driver. drizzle-orm is the ORM itself, and drizzle-kit provides CLI tools for migrations.

Step 2: Configure the Database Connection

Create a database configuration module that provides the Drizzle instance to the rest of the application.

First, add your database URL to .env:

bash
DATABASE_URL=postgresql://user:password@localhost:5432/articles_api

Install the config module:

bash
npm install @nestjs/config

Create the database module:

typescript
// src/database/database.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
 
export const DATABASE_CONNECTION = "DATABASE_CONNECTION";
 
@Global()
@Module({
  providers: [
    {
      provide: DATABASE_CONNECTION,
      useFactory: (configService: ConfigService) => {
        const client = postgres(configService.get<string>("DATABASE_URL")!);
        return drizzle(client, { schema });
      },
      inject: [ConfigService],
    },
  ],
  exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

The @Global() decorator makes the database connection available to all modules without importing DatabaseModule everywhere. The factory pattern lets us inject the ConfigService to read the database URL from environment variables.

Step 3: Define the Database Schema

Create the schema file that defines your tables in TypeScript:

typescript
// src/database/schema.ts
import {
  pgTable,
  serial,
  varchar,
  text,
  timestamp,
  boolean,
} from "drizzle-orm/pg-core";
 
export const articles = pgTable("articles", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  slug: varchar("slug", { length: 255 }).notNull().unique(),
  content: text("content").notNull(),
  excerpt: varchar("excerpt", { length: 500 }),
  published: boolean("published").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;

The schema uses Drizzle's table builder functions to define columns with their types and constraints. The $inferSelect and $inferInsert utility types extract TypeScript types from the schema, giving you type-safe objects for reading and writing data.

Create a Drizzle config file for migrations:

typescript
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
 
export default defineConfig({
  schema: "./src/database/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Generate and run the initial migration:

bash
npx drizzle-kit generate
npx drizzle-kit push

Step 4: Create the Articles Module

NestJS organizes code into modules. Generate the articles module with its controller and service:

bash
nest generate module articles
nest generate controller articles
nest generate service articles

This creates three files in src/articles/. The module ties together the controller and service, the controller handles HTTP requests, and the service contains the business logic.

Step 5: Define DTOs with Validation

DTOs (Data Transfer Objects) define the shape of incoming request data and validate it automatically. Install the validation packages:

bash
npm install class-validator class-transformer

Enable global validation in main.ts:

typescript
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
 
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    })
  );
 
  await app.listen(3000);
}
bootstrap();

The whitelist option strips properties not defined in the DTO. The forbidNonWhitelisted option returns an error if unexpected properties are sent. The transform option automatically converts plain objects to DTO class instances.

Create the DTOs:

typescript
// src/articles/dto/create-article.dto.ts
import {
  IsString,
  IsOptional,
  IsBoolean,
  MinLength,
  MaxLength,
} from "class-validator";
 
export class CreateArticleDto {
  @IsString()
  @MinLength(3)
  @MaxLength(255)
  title: string;
 
  @IsString()
  @MinLength(3)
  @MaxLength(255)
  slug: string;
 
  @IsString()
  @MinLength(10)
  content: string;
 
  @IsString()
  @IsOptional()
  @MaxLength(500)
  excerpt?: string;
 
  @IsBoolean()
  @IsOptional()
  published?: boolean;
}
typescript
// src/articles/dto/update-article.dto.ts
import { PartialType } from "@nestjs/mapped-types";
import { CreateArticleDto } from "./create-article.dto";
 
export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

The PartialType utility makes all properties optional, which is exactly what you need for PATCH updates. It preserves all the validation decorators from CreateArticleDto but applies them only when the property is present.

Step 6: Implement the Service Layer

The service contains all database operations:

typescript
// src/articles/articles.service.ts
import {
  Injectable,
  Inject,
  NotFoundException,
  ConflictException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { DATABASE_CONNECTION } from "../database/database.module";
import * as schema from "../database/schema";
import { articles } from "../database/schema";
import { CreateArticleDto } from "./dto/create-article.dto";
import { UpdateArticleDto } from "./dto/update-article.dto";
 
@Injectable()
export class ArticlesService {
  constructor(
    @Inject(DATABASE_CONNECTION)
    private readonly db: PostgresJsDatabase<typeof schema>
  ) {}
 
  async findAll() {
    return this.db
      .select()
      .from(articles)
      .orderBy(articles.createdAt);
  }
 
  async findOne(id: number) {
    const [article] = await this.db
      .select()
      .from(articles)
      .where(eq(articles.id, id));
 
    if (!article) {
      throw new NotFoundException(`Article with ID ${id} not found`);
    }
 
    return article;
  }
 
  async findBySlug(slug: string) {
    const [article] = await this.db
      .select()
      .from(articles)
      .where(eq(articles.slug, slug));
 
    if (!article) {
      throw new NotFoundException(`Article with slug "${slug}" not found`);
    }
 
    return article;
  }
 
  async create(createArticleDto: CreateArticleDto) {
    const existing = await this.db
      .select()
      .from(articles)
      .where(eq(articles.slug, createArticleDto.slug));
 
    if (existing.length > 0) {
      throw new ConflictException(
        `Article with slug "${createArticleDto.slug}" already exists`
      );
    }
 
    const [article] = await this.db
      .insert(articles)
      .values(createArticleDto)
      .returning();
 
    return article;
  }
 
  async update(id: number, updateArticleDto: UpdateArticleDto) {
    await this.findOne(id); // Throws NotFoundException if not found
 
    const [updated] = await this.db
      .update(articles)
      .set({ ...updateArticleDto, updatedAt: new Date() })
      .where(eq(articles.id, id))
      .returning();
 
    return updated;
  }
 
  async remove(id: number) {
    await this.findOne(id); // Throws NotFoundException if not found
 
    await this.db.delete(articles).where(eq(articles.id, id));
 
    return { deleted: true };
  }
}

Each method uses Drizzle's query builder, which provides full type safety. The returning() method returns the inserted or updated row, eliminating the need for a separate SELECT query. NestJS's built-in exception classes like NotFoundException and ConflictException automatically map to the correct HTTP status codes.

Step 7: Build the Controller

The controller maps HTTP endpoints to service methods:

typescript
// src/articles/articles.controller.ts
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from "@nestjs/common";
import { ArticlesService } from "./articles.service";
import { CreateArticleDto } from "./dto/create-article.dto";
import { UpdateArticleDto } from "./dto/update-article.dto";
 
@Controller("articles")
export class ArticlesController {
  constructor(private readonly articlesService: ArticlesService) {}
 
  @Get()
  findAll() {
    return this.articlesService.findAll();
  }
 
  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.articlesService.findOne(id);
  }
 
  @Get("slug/:slug")
  findBySlug(@Param("slug") slug: string) {
    return this.articlesService.findBySlug(slug);
  }
 
  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createArticleDto: CreateArticleDto) {
    return this.articlesService.create(createArticleDto);
  }
 
  @Patch(":id")
  update(
    @Param("id", ParseIntPipe) id: number,
    @Body() updateArticleDto: UpdateArticleDto
  ) {
    return this.articlesService.update(id, updateArticleDto);
  }
 
  @Delete(":id")
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param("id", ParseIntPipe) id: number) {
    return this.articlesService.remove(id);
  }
}

The ParseIntPipe automatically converts the string id parameter to a number and returns a 400 error if it cannot be parsed. The @HttpCode decorator overrides the default status code: 201 for created resources and 204 for successful deletions.

Step 8: Wire Up the Module

Update the articles module and the app module:

typescript
// src/articles/articles.module.ts
import { Module } from "@nestjs/common";
import { ArticlesController } from "./articles.controller";
import { ArticlesService } from "./articles.service";
 
@Module({
  controllers: [ArticlesController],
  providers: [ArticlesService],
  exports: [ArticlesService],
})
export class ArticlesModule {}
typescript
// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { DatabaseModule } from "./database/database.module";
import { ArticlesModule } from "./articles/articles.module";
 
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    DatabaseModule,
    ArticlesModule,
  ],
})
export class AppModule {}

Step 9: Add Global Error Handling

Create a global exception filter for consistent error responses:

typescript
// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from "@nestjs/common";
import { Response } from "express";
 
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
 
    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = "Internal server error";
 
    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      message =
        typeof exceptionResponse === "string"
          ? exceptionResponse
          : (exceptionResponse as Record<string, unknown>).message as string;
    }
 
    response.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
    });
  }
}

Register it globally in main.ts:

typescript
import { AllExceptionsFilter } from "./common/filters/http-exception.filter";
 
app.useGlobalFilters(new AllExceptionsFilter());

This ensures every error response follows the same shape, making it predictable for API consumers.

Step 10: Write Tests

NestJS has built-in testing support. Here is a unit test for the articles service:

typescript
// src/articles/articles.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { ArticlesService } from "./articles.service";
import { DATABASE_CONNECTION } from "../database/database.module";
import { NotFoundException, ConflictException } from "@nestjs/common";
 
describe("ArticlesService", () => {
  let service: ArticlesService;
  let mockDb: Record<string, jest.Mock>;
 
  beforeEach(async () => {
    mockDb = {
      select: jest.fn().mockReturnThis(),
      from: jest.fn().mockReturnThis(),
      where: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockResolvedValue([]),
      insert: jest.fn().mockReturnThis(),
      values: jest.fn().mockReturnThis(),
      returning: jest.fn().mockResolvedValue([
        {
          id: 1,
          title: "Test Article",
          slug: "test-article",
          content: "Test content for the article.",
          published: false,
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ]),
      update: jest.fn().mockReturnThis(),
      set: jest.fn().mockReturnThis(),
      delete: jest.fn().mockReturnThis(),
    };
 
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ArticlesService,
        {
          provide: DATABASE_CONNECTION,
          useValue: mockDb,
        },
      ],
    }).compile();
 
    service = module.get<ArticlesService>(ArticlesService);
  });
 
  it("should be defined", () => {
    expect(service).toBeDefined();
  });
 
  describe("findAll", () => {
    it("should return an array of articles", async () => {
      const result = await service.findAll();
      expect(result).toEqual([]);
      expect(mockDb.select).toHaveBeenCalled();
    });
  });
 
  describe("create", () => {
    it("should create and return an article", async () => {
      mockDb.where = jest.fn().mockResolvedValue([]);
 
      const dto = {
        title: "Test Article",
        slug: "test-article",
        content: "Test content for the article.",
      };
 
      const result = await service.create(dto);
      expect(result.title).toBe("Test Article");
      expect(mockDb.insert).toHaveBeenCalled();
    });
  });
});

And a controller test:

typescript
// src/articles/articles.controller.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { ArticlesController } from "./articles.controller";
import { ArticlesService } from "./articles.service";
 
describe("ArticlesController", () => {
  let controller: ArticlesController;
  let service: ArticlesService;
 
  const mockArticle = {
    id: 1,
    title: "Test Article",
    slug: "test-article",
    content: "Test content",
    excerpt: null,
    published: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  };
 
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [ArticlesController],
      providers: [
        {
          provide: ArticlesService,
          useValue: {
            findAll: jest.fn().mockResolvedValue([mockArticle]),
            findOne: jest.fn().mockResolvedValue(mockArticle),
            create: jest.fn().mockResolvedValue(mockArticle),
            update: jest.fn().mockResolvedValue(mockArticle),
            remove: jest.fn().mockResolvedValue({ deleted: true }),
          },
        },
      ],
    }).compile();
 
    controller = module.get<ArticlesController>(ArticlesController);
    service = module.get<ArticlesService>(ArticlesService);
  });
 
  it("should return all articles", async () => {
    const result = await controller.findAll();
    expect(result).toEqual([mockArticle]);
    expect(service.findAll).toHaveBeenCalled();
  });
 
  it("should return a single article", async () => {
    const result = await controller.findOne(1);
    expect(result).toEqual(mockArticle);
    expect(service.findOne).toHaveBeenCalledWith(1);
  });
 
  it("should create an article", async () => {
    const dto = {
      title: "Test Article",
      slug: "test-article",
      content: "Test content",
    };
    const result = await controller.create(dto);
    expect(result).toEqual(mockArticle);
  });
});

Run the tests:

bash
npm run test

The Complete API Endpoints

Here is a summary of all the endpoints your API exposes:

MethodEndpointDescription
GET/articlesList all articles
GET/articles/:idGet article by ID
GET/articles/slug/:slugGet article by slug
POST/articlesCreate a new article
PATCH/articles/:idUpdate an article
DELETE/articles/:idDelete an article

Next Steps

  • Pagination: Add query parameters for page and limit with Drizzle's .limit() and .offset()
  • Search: Implement full-text search using PostgreSQL's tsvector and tsquery
  • Authentication: Add JWT or session-based authentication to protect write operations
  • Rate limiting: Use @nestjs/throttler to prevent API abuse
  • Swagger documentation: Add @nestjs/swagger to generate interactive API documentation
  • Caching: Implement response caching with Redis for frequently accessed articles

FAQ

What is NestJS and why use it for a REST API?

NestJS is a progressive Node.js framework for building server-side applications using TypeScript. It uses decorators and dependency injection inspired by Angular, providing a structured architecture with modules, controllers, and services. This structure makes it easier to organize code, write tests, and scale applications compared to minimal frameworks like Express.

Why use Drizzle ORM instead of Prisma with NestJS?

Drizzle ORM provides a SQL-like query builder with full TypeScript type inference from your schema. Unlike Prisma, Drizzle has no binary engine dependency, generates types from TypeScript rather than a separate schema file, and produces queries that map directly to the SQL you would write by hand. This makes it faster in serverless environments and easier to reason about query performance.

How does validation work in NestJS?

NestJS uses the class-validator and class-transformer libraries with a global ValidationPipe. You define DTO classes with validation decorators like @IsString(), @IsEmail(), and @MinLength(). The ValidationPipe automatically validates incoming request bodies against these DTOs and returns detailed error messages for invalid data.

How do you handle errors in a NestJS API?

NestJS provides built-in exception classes like NotFoundException, BadRequestException, and ConflictException that automatically map to HTTP status codes. For custom error handling, you can create exception filters that catch specific exceptions and format the response. The framework also supports global error handling that catches unhandled exceptions.

How do you test a NestJS REST API?

NestJS has built-in support for unit and integration testing using Jest. The testing module lets you create isolated module instances with mocked dependencies. For unit tests, you mock the service layer and test controllers in isolation. For integration tests, you use supertest with the full NestJS application to test HTTP endpoints end-to-end.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.