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— notpush - [ ] 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 --noEmitlocally
Performance: What Actually Moves the Needle
- Use React Server Components for data fetching — eliminates client-side loading states for initial page load
- Database indexes —
EXPLAIN ANALYZEevery slow query, add indexes liberally - Connection pooling — use PgBouncer or Supabase's built-in pooler; serverless functions exhaust direct connections fast
- Edge middleware for auth — resolve sessions at the edge, not in your origin server
- Avoid N+1 in Prisma — use
includestrategically; log queries in dev to catch them
See these patterns in action: Smart LMS | SmartStore SaaS | Portfolio