Build REST API with NestJS: Production-Ready Guide

NestJS is one of the Node.js frameworks I reach for when a project needs more structure than a small Express application. For quick APIs, Express or Hono can be excellent. But once a backend starts growing authentication, modules, services, database access, validation, background jobs, integrations, testing having a clear architecture becomes much more valuable. That…

REST API with NestJS.
guide.md READY

NestJS is one of the Node.js frameworks I reach for when a project needs more structure than a small Express application.

For quick APIs, Express or Hono can be excellent. But once a backend starts growing authentication, modules, services, database access, validation, background jobs, integrations, testing having a clear architecture becomes much more valuable.

That is where NestJS works well.

In this tutorial, we will build a small but realistic REST API for managing users. Instead of stopping at a simple GET /users example, we will cover project structure, modules, controllers, services, DTO validation, PostgreSQL integration, error handling, and production considerations.

What We Are Building

Our API will expose the following endpoints:

GET    /api/users
GET    /api/users/:id
POST   /api/users
PATCH  /api/users/:id
DELETE /api/users/:id

A user response will look something like this:

{
  "id": 1,
  "name": "Jay Patel",
  "email": "jay@example.com",
  "createdAt": "2026-07-30T10:30:00.000Z"
}

The important part is not the size of the API. The goal is to build it in a way that still makes sense when the application grows.

Why I Like NestJS for Larger APIs

NestJS is built around modules, controllers, and providers. That separation helps prevent everything from ending up inside route handlers.

HTTP Request
     ↓
Controller
     ↓
Service
     ↓
Repository / Database
     ↓
Response

I prefer this style for medium and large APIs because each layer has a clear responsibility:

  • The controller handles routes and request parameters.
  • The service handles business logic.
  • The repository or database layer handles persistence.

This makes testing, debugging, and maintenance easier as the project grows.

Prerequisites

  • Node.js installed
  • npm installed
  • PostgreSQL available locally or remotely
  • Basic TypeScript knowledge

You can verify your Node.js installation with:

node -v
npm -v

Step 1: Install the NestJS CLI

Install the NestJS CLI globally:

npm install -g @nestjs/cli

Create a new project:

nest new nestjs-users-api

Move into the project directory:

cd nestjs-users-api

Start the development server:

npm run start:dev

By default, the application runs at:

http://localhost:3000

Step 2: Understand the Project Structure

src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts

The most important file initially is app.module.ts. Modules are used to group related functionality.

In a real project, I normally avoid placing everything inside AppModule. Instead, I separate features into domains such as:

users
auth
orders
payments
notifications

This keeps the application easier to navigate and maintain.

Step 3: Generate the Users Module

nest generate module users
nest generate service users
nest generate controller users

Your users feature should now look similar to this:

src/users/
├── users.controller.ts
├── users.module.ts
└── users.service.ts

For larger applications, I prefer this structure:

src/users/
├── dto/
├── entities/
├── users.controller.ts
├── users.module.ts
└── users.service.ts

Step 4: Create DTOs Instead of Using Raw Objects

One mistake I often see in beginner NestJS tutorials is accepting raw request data directly:

createUser(@Body() user: any)

It works, but it gives you very little protection. I prefer using DTO classes and validation.

Create:

src/users/dto/create-user.dto.ts
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength,
} from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;
}

Install the validation packages:

npm install class-validator class-transformer

NestJS provides a built-in ValidationPipe that can validate incoming request payloads against DTO rules. The official documentation recommends DTO classes because runtime validation relies on class metadata.

Step 5: Enable Global Validation

Open src/main.ts and configure validation globally:

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.setGlobalPrefix('api');

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}

bootstrap();

These options are useful for production-oriented APIs:

  • whitelist: removes unexpected properties.
  • forbidNonWhitelisted: rejects payloads containing fields you did not allow.
  • transform: helps convert incoming values into expected DTO types.

NestJS officially supports all three of these ValidationPipe options.

Step 6: Build the Users Controller

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
} from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
  ) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

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

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Patch(':id')
  update(
    @Param('id') id: string,
    @Body() dto: Partial<CreateUserDto>,
  ) {
    return this.usersService.update(Number(id), dto);
  }

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

Controllers should stay focused on HTTP concerns. NestJS describes controllers as the part responsible for handling incoming requests and returning responses, while providers such as services handle application logic.

Step 7: Add the Service Layer

For the first version, we will use an in-memory array so the architecture is easy to understand.

import {
  Injectable,
  NotFoundException,
} from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  private users = [
    {
      id: 1,
      name: 'Jay Patel',
      email: 'jay@example.com',
    },
  ];

  findAll() {
    return this.users;
  }

  findOne(id: number) {
    const user = this.users.find(
      (item) => item.id === id,
    );

    if (!user) {
      throw new NotFoundException(
        `User ${id} not found`,
      );
    }

    return user;
  }

  create(dto: CreateUserDto) {
    const user = {
      id: Date.now(),
      ...dto,
    };

    this.users.push(user);

    return user;
  }

  update(
    id: number,
    dto: Partial<CreateUserDto>,
  ) {
    const user = this.findOne(id);

    Object.assign(user, dto);

    return user;
  }

  remove(id: number) {
    const user = this.findOne(id);

    this.users = this.users.filter(
      (item) => item.id !== id,
    );

    return {
      message: 'User deleted successfully',
      user,
    };
  }
}

The important part here is using NestJS exceptions such as NotFoundException instead of manually creating inconsistent error objects.

Step 8: Test the API

Start the server:

npm run start:dev

Create a user:

curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{
  "name": "Amit Shah",
  "email": "amit@example.com"
}'

Get all users:

curl http://localhost:3000/api/users

Try sending invalid data:

{
  "name": "",
  "email": "wrong-email"
}

The API should now reject the request with a validation error instead of silently accepting bad input.

Move from Memory to PostgreSQL

An in-memory array is useful for learning, but it disappears whenever the server restarts. For a real application, we need persistent storage.

In this example, we will use PostgreSQL with TypeORM.

npm install @nestjs/typeorm typeorm pg

Step 9: Create the User Entity

Create src/users/entities/user.entity.ts:

import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @CreateDateColumn()
  createdAt: Date;
}

Step 10: Configure PostgreSQL

I do not recommend hard-coding database credentials directly inside app.module.ts.

npm install @nestjs/config

Create a .env file:

PORT=3000

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your_password
DB_DATABASE=nestjs_api

Then configure TypeORM:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),

    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT),
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      autoLoadEntities: true,
      synchronize: false,
    }),

    UsersModule,
  ],
})
export class AppModule {}

I intentionally use synchronize: false. NestJS’s database documentation warns against using synchronize: true in production because automatic schema synchronization can put production data at risk.

Step 11: Register the User Entity

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

import { User } from './entities/user.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
  ],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

Step 12: Replace the Array with a Repository

import {
  Injectable,
  NotFoundException,
} from '@nestjs/common';

import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  findAll() {
    return this.userRepository.find({
      order: {
        id: 'DESC',
      },
    });
  }

  async findOne(id: number) {
    const user =
      await this.userRepository.findOne({
        where: { id },
      });

    if (!user) {
      throw new NotFoundException(
        `User ${id} not found`,
      );
    }

    return user;
  }

  async create(dto: CreateUserDto) {
    const user =
      this.userRepository.create(dto);

    return this.userRepository.save(user);
  }

  async update(
    id: number,
    dto: Partial<CreateUserDto>,
  ) {
    const user = await this.findOne(id);

    Object.assign(user, dto);

    return this.userRepository.save(user);
  }

  async remove(id: number) {
    const user = await this.findOne(id);

    await this.userRepository.remove(user);

    return {
      message: 'User deleted successfully',
    };
  }
}

Create a Proper UpdateUserDto

Using Partial<CreateUserDto> works, but I prefer a dedicated DTO because it keeps validation and API contracts clear.

npm install @nestjs/mapped-types
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(
  CreateUserDto,
) {}

Validate Route IDs Properly

Instead of manually writing Number(id), NestJS provides ParseIntPipe.

@Get(':id')
findOne(
  @Param('id', ParseIntPipe) id: number,
) {
  return this.usersService.findOne(id);
}

That means a request such as /users/abc is rejected before it reaches your service. ParseIntPipe is included among NestJS’s built-in validation and transformation pipes.

Handle Duplicate Email Addresses Cleanly

Because our database uses a unique email column, I prefer detecting duplicates before saving so the client receives a useful response instead of a raw database error.

const existingUser =
  await this.userRepository.findOne({
    where: {
      email: dto.email,
    },
  });

if (existingUser) {
  throw new ConflictException(
    'A user with this email already exists',
  );
}

Recommended API Response Strategy

I normally avoid wrapping every successful response unless the project has a good reason to do so.

This is perfectly reasonable:

{
  "id": 12,
  "name": "Amit Shah",
  "email": "amit@example.com"
}

You do not always need:

{
  "success": true,
  "status": 200,
  "data": {
    ...
  }
}

Response envelopes can be useful when you need pagination metadata or standardized frontend contracts, but they should solve a real problem rather than add unnecessary nesting.

Production Mistakes I Would Avoid

1. Putting Business Logic in Controllers

Controllers should remain thin. Database queries, email logic, validation workflows, and business rules belong in services or dedicated components.

2. Accepting Any Everywhere

@Body() body: any

DTOs and validation exist to protect the boundary of your application. Use them.

3. Hard-Coding Secrets

Database passwords, JWT secrets, API keys, and third-party credentials should never be committed directly into your source code.

4. Returning Raw Database Errors

Your clients should receive meaningful HTTP errors, not internal PostgreSQL or TypeORM messages.

5. Using synchronize: true in Production

Use migrations for controlled database changes. NestJS explicitly warns against production use of automatic TypeORM schema synchronization.

6. Forgetting Pagination

A route returning 20 records is easy. A route returning two million records is a production incident waiting to happen.

GET /users?page=1&limit=20

7. Logging Sensitive Information

Avoid logging passwords, access tokens, refresh tokens, complete authorization headers, or unnecessary personal information.

What I Would Add Next

  • JWT authentication
  • Refresh tokens
  • Role-based authorization
  • Database migrations
  • Pagination and filtering
  • Swagger/OpenAPI documentation
  • Unit tests
  • Integration tests
  • Rate limiting
  • Structured logging
  • Docker
  • CI/CD
  • Production monitoring

That is where a basic CRUD tutorial starts becoming an API you can confidently maintain in production.

NestJS vs Lighter Frameworks

I do not think NestJS is automatically the best choice for every Node.js backend.

For a tiny microservice, edge function, or lightweight API, I may prefer something smaller such as Hono. For larger applications with multiple modules and developers, however, NestJS provides architectural conventions that can prevent the codebase from becoming difficult to maintain.

If you want to understand that trade-off in more detail, read my NestJS vs Hono comparison.

Final Thoughts

Building a REST API with NestJS is easy. Building one that remains understandable after months of feature development is the harder part.

The structure I recommend is simple:

Controller
→ Service
→ Repository
→ Database

Then gradually add validation, configuration, migrations, authentication, testing, logging, and monitoring as your project matures.

If you are learning NestJS, build the simple version first. Once you understand how modules, controllers, providers, and dependency injection fit together, replace the in-memory storage with PostgreSQL and add production concerns one at a time.

That progression will teach you much more than copying a large starter repository without understanding why each abstraction exists.

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Previous guide
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.