NestJS
13 Apr 2026
10 MIN READ

NestJS + Prisma + PostgreSQL: The Production-Ready Freelance Stack

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:

FeaturePostgreSQLMySQLMongoDB
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

Mentioned Technologies

#NestJS#Prisma#PostgreSQL#Backend#NodeJS#TypeScript#Freelance#Web Development

Frequently Asked Questions

NestJS gives structure, Prisma provides type-safe database access, and PostgreSQL is a reliable relational database. Together they form a productive, production-ready backend stack.

Yes. With proper connection pooling and migrations, Prisma offers type safety and developer speed without sacrificing reliability, and is used in production by many teams.

With the setup in this guide (auth, Docker, and folder structure) you can have a production-ready foundation running in about an hour.

Built this in production

EDUPORTAL — LMS & Online Examination Platform

Learning Management System — read the case study

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts