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
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:
npm i -g @nestjs/cli
nest new articles-api
cd articles-apiChoose 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:
npm install drizzle-orm postgres
npm install -D drizzle-kit @types/nodeThe 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:
DATABASE_URL=postgresql://user:password@localhost:5432/articles_apiInstall the config module:
npm install @nestjs/configCreate the database module:
// 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:
// 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:
// 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:
npx drizzle-kit generate
npx drizzle-kit pushStep 4: Create the Articles Module
NestJS organizes code into modules. Generate the articles module with its controller and service:
nest generate module articles
nest generate controller articles
nest generate service articlesThis 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:
npm install class-validator class-transformerEnable global validation in main.ts:
// 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:
// 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;
}// 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:
// 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:
// 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:
// 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 {}// 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:
// 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:
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:
// 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:
// 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:
npm run testThe Complete API Endpoints
Here is a summary of all the endpoints your API exposes:
| Method | Endpoint | Description |
|---|---|---|
| GET | /articles | List all articles |
| GET | /articles/:id | Get article by ID |
| GET | /articles/slug/:slug | Get article by slug |
| POST | /articles | Create a new article |
| PATCH | /articles/:id | Update an article |
| DELETE | /articles/:id | Delete an article |
Next Steps
- ›Pagination: Add query parameters for
pageandlimitwith Drizzle's.limit()and.offset() - ›Search: Implement full-text search using PostgreSQL's
tsvectorandtsquery - ›Authentication: Add JWT or session-based authentication to protect write operations
- ›Rate limiting: Use
@nestjs/throttlerto prevent API abuse - ›Swagger documentation: Add
@nestjs/swaggerto 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.
Related Articles
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
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
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.