NestJS + Prisma + PostgreSQL: The Production-Ready Freelance Stack
- Why I Standardized on One Stack
- Why NestJS
- Why Prisma
- 1\. Schema as Single Source of Truth
- 2\. Type Safety End to End
- 3\. Migrations That Actually Work
- Why PostgreSQL
- Complete Project Setup
- Step 1 — Initialize NestJS
- Step 2 — Install and Configure Prisma
- Step 3 — Create Prisma Service
- Step 4 — Docker for Local Development
- Step 5 — Environment Configuration
- Authentication — My Standard Setup
- Common Patterns I Use on Every Project
- Pagination
- Global Response Format
- Global Exception Filter
- Database Design Principles I Follow
- 1\. Always Add Indexes for Common Queries
- 2\. Use Select to Avoid Over-fetching
- 3\. Use Transactions for Multi-step Operations
- Performance Tips I Apply to Every Project
- Why This Stack Works for Freelance
- Starter Template
- Final Thoughts
Why I use NestJS, Prisma, and PostgreSQL on every freelance project. Real setup, folder structure, schema design, and lessons learned from production builds.
Why I Standardized on One Stack
When I started freelancing, every project had a different stack.
One client wanted Express. Another wanted Fastify. One had MongoDB. Another had MySQL. Every project meant relearning tooling, folder structure, and patterns from scratch.
That was slow and exhausting.
So I made a decision — standardize on one backend stack that could handle anything a client throws at me:
NestJS → framework
Prisma → ORM
PostgreSQL → database
Redis → caching + queues
Docker → local development
Six months later — this stack has powered every project I've delivered. Here's exactly why, and how I set it up.
If your project needs background job processing alongside this stack, I've written a complete guide on building a dynamic notification engine with BullMQ and Redis.
Why NestJS
I've used Express, Fastify, and NestJS in production. NestJS wins for freelance work for one reason:
Structure you don't have to invent.
With Express or Fastify, you spend the first week deciding folder structure, dependency management, and conventions. With NestJS, those decisions are already made.
src/
├── modules/
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ └── dto/
│ │ ├── login.dto.ts
│ │ └── register.dto.ts
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ └── users.service.ts
│ └── posts/
│ ├── posts.module.ts
│ ├── posts.controller.ts
│ └── posts.service.ts
├── common/
│ ├── guards/
│ ├── interceptors/
│ ├── decorators/
│ └── filters/
├── prisma/
│ └── prisma.service.ts
└── main.ts
Every module follows the same pattern. Every developer who joins the project immediately understands the structure. Every client gets consistent, maintainable code.
Why Prisma
I've used raw SQL, Sequelize, TypeORM, and Prisma. Prisma wins for three reasons:
1. Schema as Single Source of Truth
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
@@index([authorId])
@@index([published, createdAt(sort: Desc)])
}
enum Role {
USER
ADMIN
}
One file. Everything is defined here — types, relations, indexes, constraints. No more hunting through multiple files to understand the database structure.
2. Type Safety End to End
// TypeScript knows exactly what this returns
const user = await this.prisma.user.findUnique({
where: { email },
include: { posts: true },
});
// user.posts is Post[] — fully typed, no guessing
// user.nonExistentField → TypeScript error at compile time
3. Migrations That Actually Work
# Make a schema change
# Prisma generates the migration automatically
npx prisma migrate dev --name add-user-avatar
# Generates:
# prisma/migrations/20260401_add_user_avatar/migration.sql
# ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
No writing raw SQL migrations. No merge conflicts on migration files. Clean, trackable history of every schema change.
Why PostgreSQL
MongoDB is flexible. MySQL is popular. But PostgreSQL is the right choice for most freelance projects:
| Feature | PostgreSQL | MySQL | MongoDB |
|---|---|---|---|
| ACID compliance | ✅ Full | ✅ Full | ⚠️ Partial |
| JSON support | ✅ Excellent | ⚠️ Limited | ✅ Native |
| Full-text search | ✅ Built-in | ⚠️ Limited | ✅ Built-in |
| Complex queries | ✅ Best-in-class | ✅ Good | ⚠️ Limited |
| Indexing options | ✅ Extensive | ✅ Good | ✅ Good |
| Managed hosting | ✅ Many options | ✅ Many options | ✅ Atlas |
PostgreSQL handles relational data, JSON columns, full-text search, and complex aggregations — all in one database. I've never hit a limitation that required switching.
Complete Project Setup
Here's my exact setup process for every new freelance project.
Step 1 — Initialize NestJS
npm i -g @nestjs/cli
nest new project-name
cd project-name
Step 2 — Install and Configure Prisma
npm install prisma @prisma/client
npx prisma init
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/dbname?schema=public"
Step 3 — Create Prisma Service
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
// src/prisma/prisma.module.ts
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global() // available everywhere without importing
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
Step 4 — Docker for Local Development
# docker-compose.yml
version: "3.8"
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
docker-compose up -d
Local database running in 30 seconds. No installation required.
Step 5 — Environment Configuration
// src/config/config.module.ts
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env",
validate: (config) => {
// Validate required env vars on startup
const required = ["DATABASE_URL", "JWT_SECRET"];
required.forEach((key) => {
if (!config[key]) throw new Error(`Missing env: ${key}`);
});
return config;
},
}),
],
})
export class AppConfigModule {}
Authentication — My Standard Setup
Every freelance project needs auth. Here's my standard implementation:
// src/modules/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async register(dto: RegisterDto) {
const exists = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (exists) {
throw new ConflictException("Email already registered");
}
const hashedPassword = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: {
email: dto.email,
name: dto.name,
password: hashedPassword,
},
select: {
id: true,
email: true,
name: true,
role: true,
// Never return password
},
});
const token = this.generateToken(user.id);
return { user, token };
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user || !(await bcrypt.compare(dto.password, user.password))) {
throw new UnauthorizedException("Invalid credentials");
}
const token = this.generateToken(user.id);
return { user: { id: user.id, email: user.email }, token };
}
private generateToken(userId: string) {
return this.jwtService.sign({ sub: userId });
}
}
Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.
Common Patterns I Use on Every Project
Pagination
// src/common/dto/pagination.dto.ts
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
}
// Usage in any service
async findAll(dto: PaginationDto) {
const { page, limit } = dto;
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.post.count(),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
Global Response Format
// src/common/interceptors/response.interceptor.ts
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
// Every API response looks like:
// {
// "success": true,
// "data": { ... },
// "timestamp": "2026-04-01T..."
// }
Global Exception Filter
// src/common/filters/exception.filter.ts
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: "Internal server error";
response.status(status).json({
success: false,
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}
Database Design Principles I Follow
1. Always Add Indexes for Common Queries
model Order {
id String @id @default(cuid())
userId String
status String
createdAt DateTime @default(now())
// Index for "get all orders for a user sorted by date"
@@index([userId, createdAt(sort: Desc)])
// Index for "get all pending orders"
@@index([status])
}
2. Use Select to Avoid Over-fetching
// ❌ Returns all fields including sensitive ones
const users = await this.prisma.user.findMany();
// ✅ Returns only what the client needs
const users = await this.prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
// password → not selected, never exposed
},
});
3. Use Transactions for Multi-step Operations
// ❌ Two separate operations — one can fail without the other rolling back
await this.prisma.order.create({ data: orderData });
await this.prisma.inventory.update({
where: { id },
data: { stock: { decrement: 1 } },
});
// ✅ Atomic — both succeed or both fail
await this.prisma.$transaction([
this.prisma.order.create({ data: orderData }),
this.prisma.inventory.update({
where: { id: productId },
data: { stock: { decrement: 1 } },
}),
]);
Performance Tips I Apply to Every Project
1. Use select everywhere — never return more data than needed
2. Add indexes before you need them — schema design time is cheaper than production debugging
3. Use Promise.all for independent queries:
// ❌ Sequential — waits for each query
const user = await this.prisma.user.findUnique({ where: { id } });
const posts = await this.prisma.post.findMany({ where: { authorId: id } });
// ✅ Parallel — runs simultaneously
const [user, posts] = await Promise.all([
this.prisma.user.findUnique({ where: { id } }),
this.prisma.post.findMany({ where: { authorId: id } }),
]);
4. Use _count instead of fetching related records:
// ❌ Fetches all posts just to count them
const user = await this.prisma.user.findUnique({
where: { id },
include: { posts: true }, // loads all post data
});
const postCount = user.posts.length;
// ✅ Single query, just the count
const user = await this.prisma.user.findUnique({
where: { id },
include: { _count: { select: { posts: true } } },
});
const postCount = user._count.posts;
These same optimization patterns are what helped me cut API response time by 50% across 15+ endpoints in a real production app.
Why This Stack Works for Freelance
Three reasons this combination works specifically for freelance work:
1. Speed of delivery — standardized setup means I can have a production-ready API skeleton running in under an hour. Clients get working endpoints faster.
2. Maintainability — when a client comes back 6 months later with changes, the codebase is still clean and readable. No spaghetti code from rushed decisions.
3. Confidence — I've battle-tested this stack across multiple production projects. I know exactly where it excels and where to watch out. No surprises during client delivery.
If you want to see this stack in action, I've written a detailed breakdown of how I used it to build a THE DESIGN ETHOS — DESIGN COMMUNITY & TEMPLATE MARKETPLACE PLATFORM — in production.
Starter Template
I've packaged this entire setup as a ready-to-use starter:
# Clone my NestJS + Prisma + PostgreSQL starter
git clone https://github.com/arihantjain916/nestjs-prisma-starter
cd nestjs-prisma-starter
# Start database
docker-compose up -d
# Install dependencies
npm install
# Run migrations
npx prisma migrate dev
# Start server
npm run start:dev
Includes: auth, pagination, global response format, exception filter, Docker setup, and Prisma configuration out of the box.
Final Thoughts
The best stack is the one you know deeply.
NestJS, Prisma, and PostgreSQL aren't the only way to build backends. But for freelance work — where speed, reliability, and maintainability all matter equally — this combination has never let me down.
If you're building your first freelance backend or looking to standardize your own stack, give this combination a try. The learning curve is worth it.
Working on a freelance project and need a reliable backend engineer? Let's discuss your project — I'm currently available for new work.
Or explore my backend services and case studies to see what I've built.
Have questions about the setup or want to see a specific pattern covered in more detail? Drop a message