Abstract blocks representing a unified software stack
Architecture

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.

Protize Team
#nextjs #nestjs #postgresql #fullstack #javascript

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.

Enjoyed this article?

Subscribe to get more insights like this delivered to your inbox.