Building Production REST APIs with NestJS, TypeORM, and PostgreSQL

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:
| Feature | NestJS | Express | Fastify | Hono |
|---|---|---|---|---|
| Architecture | Opinionated (modules) | Minimal | Minimal | Minimal |
| TypeScript | First-class | Add-on | Add-on | First-class |
| DI Container | Built-in | None | None | None |
| CLI | Full scaffolding | None | None | None |
| Testing | Built-in utilities | Manual | Manual | Manual |
| Learning Curve | Moderate | Low | Low | Low |
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-apiChoose npm when prompted. Then navigate into the project:
cd user-apiInstall 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/bcryptYour 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=1hUpdate 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 usersCreate 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 authCreate 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.tsStep 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:covStep 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:devTest 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/swaggerfor auto-generated API docs - Rate limiting — Use
@nestjs/throttlerto protect against abuse - Caching — Implement Redis caching with
@nestjs/cache-manager - File uploads — Add
@nestjs/platform-expresswith Multer for file handling - WebSockets — Add real-time features with
@nestjs/websockets - GraphQL — Replace REST with
@nestjs/graphqland 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.
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.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Building Production-Ready REST APIs with FastAPI, PostgreSQL, and Docker
Learn how to build, test, and deploy a production-grade REST API using Python's FastAPI framework with PostgreSQL, SQLAlchemy, Alembic migrations, and Docker Compose — from zero to deployment.