← Back to tech insights

February 19, 2026 · 11 min

Building Scalable Cloud SaaS with Next.js, TypeScript, and PostgreSQL

A practical guide to architecting a production-ready SaaS platform — the full stack decisions, patterns, and trade-offs I've refined across multiple SaaS projects.

Building Scalable Cloud SaaS with Next.js, TypeScript, and PostgreSQL

TL;DR: After building Smart LMS, SmartStore SaaS, and AutomateLanka, I've settled on a stack and set of patterns that consistently deliver production-ready SaaS products. Here's the full blueprint.


The Stack

Before anything else, here's what I use and why:

| Layer | Technology | Why | |-------|-----------|-----| | Framework | Next.js 15 (App Router) | Full-stack, RSC, edge-ready | | Language | TypeScript | Safety, DX, refactoring | | Database | PostgreSQL | Mature, ACID, JSON support | | ORM | Prisma | Type-safe queries, migrations | | Auth | NextAuth.js | Flexible, Next.js native | | Hosting | Vercel | Zero-config, edge network | | Payments | Stripe | Industry standard | | Email | Resend | Modern, developer-friendly | | Storage | Cloudflare R2 | S3-compatible, cheaper egress |


Project Structure

The most important architectural decision is how you organize your code. I use a feature-based structure:

src/
├── app/                    # Next.js App Router
│   ├── (auth)/             # Route group: public auth pages
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/        # Route group: authenticated UI
│   │   ├── dashboard/
│   │   ├── courses/
│   │   └── settings/
│   └── api/                # API routes
│       ├── courses/
│       ├── users/
│       └── webhooks/
│
├── features/               # Feature modules (collocated logic)
│   ├── courses/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── actions/        # Server Actions
│   │   ├── queries/        # Database queries
│   │   └── types.ts
│   └── billing/
│       ├── components/
│       ├── stripe.ts
│       └── webhooks.ts
│
├── lib/                    # Shared utilities
│   ├── db.ts               # Prisma client singleton
│   ├── auth.ts             # Auth config
│   └── errors.ts           # Error classes
│
└── types/                  # Global type definitions

The key insight: keep everything related to a feature together. Don't separate components, hooks, and queries into top-level folders — it kills navigation.


Database: Prisma with PostgreSQL

The Prisma Client Singleton

Always use a singleton in Next.js to avoid exhausting connection pools during hot reload:

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Schema Conventions I Follow

model Course {
  // IDs: always CUID for public-facing, never auto-increment integers
  id          String   @id @default(cuid())
  
  // Timestamps: always include both
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  
  // Soft deletes: don't actually delete records
  deletedAt   DateTime?
  
  // Indexes: always index foreign keys and commonly filtered columns
  tenantId    String
  @@index([tenantId])
  @@index([tenantId, deletedAt])
}

Soft Deletes Middleware

// lib/db.ts
prisma.$use(async (params, next) => {
  // Automatically filter out soft-deleted records
  if (params.model && params.action === 'findMany') {
    params.args = params.args || {};
    params.args.where = {
      ...params.args.where,
      deletedAt: null,
    };
  }
  return next(params);
});

Server Actions Over API Routes

Next.js Server Actions dramatically simplify the full-stack loop. Instead of writing API route → fetch → state update, you call a function directly:

// features/courses/actions/create-course.ts
'use server';

import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { prisma } from '@/lib/db';
import { getServerTenant } from '@/lib/auth';

const CreateCourseSchema = z.object({
  title: z.string().min(3).max(100),
  description: z.string().min(10),
});

export async function createCourse(formData: FormData) {
  const tenant = await getServerTenant();
  
  const parsed = CreateCourseSchema.safeParse({
    title: formData.get('title'),
    description: formData.get('description'),
  });
  
  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }
  
  const course = await prisma.course.create({
    data: {
      ...parsed.data,
      tenantId: tenant.id,
    },
  });
  
  revalidatePath('/dashboard/courses');
  return { success: true, course };
}
// Client component — no useState, no fetch, no useEffect
<form action={createCourse}>
  <input name="title" />
  <input name="description" />
  <button type="submit">Create</button>
</form>

API Routes: When You Still Need Them

Use API routes for:

  • Webhooks (Stripe, GitHub)
  • Public REST endpoints (for mobile apps or third parties)
  • Long-running operations that need streaming
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = headers().get('stripe-signature')!;

  let event: Stripe.Event;
  
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleSubscriptionCreate(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCancel(event.data.object);
      break;
  }

  return new Response('OK');
}

Error Handling

I use typed error classes to make error handling predictable:

// lib/errors.ts
export class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number = 500,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 'NOT_FOUND', 404);
  }
}

export class UnauthorizedError extends AppError {
  constructor() {
    super('Unauthorized', 'UNAUTHORIZED', 401);
  }
}

export class PlanLimitError extends AppError {
  constructor(feature: string) {
    super(`${feature} limit reached on your current plan`, 'PLAN_LIMIT', 403);
  }
}

// In API routes:
export function handleApiError(error: unknown): Response {
  if (error instanceof AppError) {
    return Response.json(
      { error: error.message, code: error.code },
      { status: error.statusCode }
    );
  }
  
  console.error('Unhandled error:', error);
  return Response.json({ error: 'Internal server error' }, { status: 500 });
}

Environment Configuration

// lib/env.ts — Validated env at startup
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  RESEND_API_KEY: z.string(),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

export const env = envSchema.parse(process.env);

This crashes at startup if any env variable is missing — much better than getting cryptic errors in production.


Caching Strategy

// Stable data: cache aggressively
const tenant = await prisma.tenant.findUnique({
  where: { slug },
  cacheStrategy: { ttl: 300 },  // 5 minutes
});

// User data: short cache
const courses = await fetch('/api/courses', {
  next: { revalidate: 60 },  // 1 minute
});

// Real-time data: no cache
const notifications = await fetch('/api/notifications', {
  cache: 'no-store',
});

The Deployment Checklist

Before every production deployment:

  • [ ] Run prisma migrate deploy — not push
  • [ ] Verify all env variables in Vercel dashboard
  • [ ] Test Stripe webhook with stripe trigger
  • [ ] Check database connection pool limits (Supabase: 100 connections, use PgBouncer)
  • [ ] Verify CORS on API routes for mobile clients
  • [ ] Run tsc --noEmit locally

Performance: What Actually Moves the Needle

  1. Use React Server Components for data fetching — eliminates client-side loading states for initial page load
  2. Database indexesEXPLAIN ANALYZE every slow query, add indexes liberally
  3. Connection pooling — use PgBouncer or Supabase's built-in pooler; serverless functions exhaust direct connections fast
  4. Edge middleware for auth — resolve sessions at the edge, not in your origin server
  5. Avoid N+1 in Prisma — use include strategically; log queries in dev to catch them

See these patterns in action: Smart LMS | SmartStore SaaS | Portfolio