The Ultimate Stack: Next.js, NestJS, and PostgreSQL
Why combining Next.js for the frontend, NestJS for the API, and PostgreSQL for data is the perfect stack for modern web development.
Choosing a tech stack is one of the most consequential decisions a development team makes. Pick the wrong one, and you’ll spend years fighting the framework instead of building features. Pick the right one, and it fades into the background — letting you focus entirely on solving problems for your users.
After years of building production applications, one combination stands out as exceptional for modern web applications: Next.js + NestJS + PostgreSQL. This stack offers a perfect balance of developer experience, scalability, and operational simplicity. In this blog, we’ll explain exactly why, and show you how to wire the entire thing together.
Why This Stack Specifically?
There are dozens of valid ways to build a web application. So why this combination?
The TypeScript Throughout Advantage
The most underrated aspect of this stack is that TypeScript runs end-to-end. Your database schema generates types (via Prisma), those types flow through your NestJS services and controllers, and the same types can be shared with your Next.js frontend. The result:
- No type drift between frontend and backend
- IntelliSense everywhere — autocomplete for database fields in React components
- Refactoring is safe — rename a field and TypeScript shows you every place that needs to change
- Fewer runtime bugs — many bugs that would only be caught in production are caught at compile time
Clear Separation of Concerns
Each layer has one job:
- Next.js: UI rendering, SEO, user interaction, client-side state
- NestJS: Business logic, data validation, authentication, API design
- PostgreSQL: Data storage, integrity constraints, complex queries
This separation means you can swap any layer independently. Switch from REST to GraphQL in NestJS without touching Next.js. Move from PostgreSQL to a different database without changing your API contracts.
The Three Pillars in Detail
Next.js — The Frontend King
Next.js is React with superpowers. It handles:
Rendering: Choose the right strategy per page — static generation for blog posts, server-side rendering for personalized dashboards, client-side rendering for highly interactive sections.
Routing: File-based routing that’s intuitive and powerful. Create app/blog/[slug]/page.tsx and you instantly have a dynamic route.
Optimization: Built-in image optimization, font optimization, code splitting, and prefetching that would take weeks to configure manually.
API Routes: Lightweight endpoints for webhooks, form submissions, and simple operations — without spinning up a separate server.
NestJS — The Backend Enforcer
NestJS takes the wild west of Node.js backends and gives it law and order. Built on top of Express (or Fastify), it brings:
Modules: Every feature is encapsulated in a module. The PostsModule contains everything related to posts — nothing more, nothing less.
Dependency Injection: Services are injected rather than imported directly. This makes unit testing trivially easy — inject a mock database, test your business logic.
Decorators: Clean, readable code. @Get(':id') is more expressive than router.get('/:id', handler).
Validation Pipes: Automatically validate and transform incoming request data using class-validator. Bad data never reaches your service layer.
PostgreSQL — The Data Backbone
PostgreSQL is battle-tested and trusted by companies from startups to Fortune 500. It offers:
ACID Compliance: Atomicity, Consistency, Isolation, Durability. Your data is safe, even when servers crash mid-operation.
Advanced Data Types: JSON/JSONB for flexible data, arrays, full-text search, PostGIS for geospatial — all built in.
Row-Level Security: Define access policies at the database level for multi-tenant applications.
Excellent Tooling: Works beautifully with Prisma, TypeORM, and Drizzle ORM.
Setting Up the Full Stack
1. Database with Prisma
Prisma is the ORM of choice for this stack. It generates a fully typed client from your schema:
npm install prisma @prisma/client
npx prisma init
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
passwordHash String
role Role @default(READER)
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model Post {
id Int @id @default(autoincrement())
title String
slug String @unique
excerpt String
content String
coverImage String?
category String
tags String[]
published Boolean @default(false)
readingTime Int @default(0)
author User @relation(fields: [authorId], references: [id])
authorId Int
comments Comment[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([category])
@@index([authorId])
}
model Comment {
id Int @id @default(autoincrement())
content String
author User @relation(fields: [authorId], references: [id])
authorId Int
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId Int
createdAt DateTime @default(now())
}
enum Role {
ADMIN
EDITOR
READER
}
# Apply the schema to your database
npx prisma migrate dev --name init
# Generate the TypeScript client
npx prisma generate
# View your database in a browser UI
npx prisma studio
2. NestJS Prisma Integration
// 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 { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Makes PrismaService available everywhere without importing PrismaModule
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
3. A Complete NestJS CRUD Resource
// src/posts/posts.controller.ts
import {
Controller, Get, Post, Body, Param, Delete, Patch,
ParseIntPipe, Query, UseGuards, HttpCode, HttpStatus,
} from '@nestjs/common';
import { PostsService } from './posts.service';
import { CreatePostDto, UpdatePostDto } from '@my-enterprise/shared-dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
// Public — anyone can list published posts
@Get()
findAll(
@Query('page') page = '1',
@Query('limit') limit = '10',
@Query('category') category?: string,
) {
return this.postsService.findAll(+page, +limit, category);
}
// Public — anyone can view a post
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.postsService.findOne(id);
}
// Protected — only authenticated editors/admins can create
@Post()
@UseGuards(JwtAuthGuard)
@Roles('EDITOR', 'ADMIN')
create(@Body() dto: CreatePostDto, @CurrentUser() user: any) {
return this.postsService.create(dto, user.id);
}
// Protected — only authenticated users can update
@Patch(':id')
@UseGuards(JwtAuthGuard)
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePostDto,
) {
return this.postsService.update(id, dto);
}
// Protected — only admins can delete
@Delete(':id')
@UseGuards(JwtAuthGuard)
@Roles('ADMIN')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
return this.postsService.remove(id);
}
}
4. Next.js Frontend Consuming the API
// lib/api.ts — Type-safe API client
import type { Post, PaginatedResponse } from '@my-enterprise/shared-types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export async function fetchPosts(page = 1, category?: string): Promise<PaginatedResponse<Post>> {
const params = new URLSearchParams({ page: String(page) });
if (category) params.set('category', category);
const res = await fetch(`${API_BASE}/posts?${params}`, {
next: { revalidate: 60, tags: ['posts'] },
});
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.statusText}`);
return res.json();
}
export async function fetchPost(id: number): Promise<Post> {
const res = await fetch(`${API_BASE}/posts/${id}`, {
next: { revalidate: 3600 },
});
if (!res.ok) throw new Error(`Post ${id} not found`);
return res.json();
}
export async function createPost(
dto: Partial<Post>,
token: string
): Promise<Post> {
const res = await fetch(`${API_BASE}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(dto),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
}
// app/blog/page.tsx — Server Component
import { fetchPosts } from '@/lib/api';
import { ArticleCard } from '@/components/ArticleCard';
interface PageProps {
searchParams: { page?: string; category?: string };
}
export default async function BlogPage({ searchParams }: PageProps) {
const page = Number(searchParams.page) || 1;
const { data: posts, pagination } = await fetchPosts(page, searchParams.category);
return (
<main className="max-w-6xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-12">Blog</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map(post => (
<ArticleCard key={post.id} post={post} />
))}
</div>
{/* Pagination */}
<div className="flex justify-center gap-4 mt-12">
{pagination.hasPrev && (
<a href={`/blog?page=${page - 1}`}
className="px-4 py-2 border rounded hover:bg-gray-50">
← Previous
</a>
)}
<span className="px-4 py-2 text-gray-500">
Page {pagination.page} of {pagination.totalPages}
</span>
{pagination.hasNext && (
<a href={`/blog?page=${page + 1}`}
className="px-4 py-2 border rounded hover:bg-gray-50">
Next →
</a>
)}
</div>
</main>
);
}
Environment Configuration
Keep your environment variables organized:
# .env.development
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_dev"
JWT_ACCESS_SECRET="dev-access-secret-change-in-production"
JWT_REFRESH_SECRET="dev-refresh-secret-change-in-production"
NEXT_PUBLIC_API_URL="http://localhost:3001"
REVALIDATION_SECRET="dev-revalidation-secret"
# .env.production (keep in CI/CD secrets, never commit)
DATABASE_URL="postgresql://..."
JWT_ACCESS_SECRET="..."
JWT_REFRESH_SECRET="..."
NEXT_PUBLIC_API_URL="https://api.yourapp.com"
Running the Full Stack Locally
# Start PostgreSQL with Docker
docker run -d \
--name myapp-db \
-e POSTGRES_DB=myapp_dev \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16-alpine
# Run migrations
npx prisma migrate dev
# Start NestJS API (in one terminal)
nx serve api
# Runs on http://localhost:3001
# Start Next.js (in another terminal)
nx serve web
# Runs on http://localhost:3000
Conclusion
The Next.js + NestJS + PostgreSQL stack is not just a collection of popular tools — it’s a coherent philosophy. TypeScript throughout the entire codebase means fewer bugs and faster refactoring. Clear separation between UI, business logic, and data means your codebase stays maintainable as it grows. And Prisma bridges PostgreSQL and NestJS with an API so ergonomic it’s almost pleasant to write database queries.
Whether you’re building a blog, a SaaS product, an e-commerce platform, or a referral system — this stack gives you the tools to build it right the first time and the confidence to scale it when you need to.
Related Articles
Continue reading about Architecture
API Design Best Practices: Building APIs That Developers Love
A comprehensive guide to designing REST APIs that are intuitive, consistent, and a joy to consume — covering naming, response structure, pagination, error handling, versioning, and documentation.
Designing a Scalable Referral System with Node.js and React
Architecting a robust, secure, and easily trackable referral system using modern JavaScript technologies.