Skip to main content
Mouhssine Lakhili
Contact

SaaS Architecture with Next.js, TypeScript and PostgreSQL: A 2026 Guide

How to structure a scalable SaaS application with Next.js 16, TypeScript and PostgreSQL: modular monolith, multi-tenancy with row-level security, authentication, billing, background jobs and production readiness.

Mouhssine Lakhili17 min read

The short answer

If you are building a scalable SaaS application in 2026, Next.js 16 + TypeScript + PostgreSQL is still one of the highest-leverage stacks you can pick: one language from the browser to the database, a framework that handles server rendering and APIs, and a relational database that can absorb far more load than a young product will ever see.

The architecture I recommend by default fits in one sentence: a modular monolith, a single PostgreSQL database shared by all customers with an organization_id column protected by row-level security, authorization checked in the data access layer, and everything slow or external pushed to a queue.

This guide is for CTOs and founders choosing a stack, developers starting a B2B SaaS, and recruiters who want to see how I reason about systems. It reflects my own practice: this portfolio (Next.js, TypeScript, technical SEO), the OpsPilot project (PostgreSQL, Redis, observability), and my years as an apprentice developer working with React, Node.js and PostgreSQL. It is not the story of one specific client SaaS; it is the architecture I propose when someone hands me a project, limits included.

What "scalable" means for a SaaS

Scalability usually gets reduced to traffic. For a SaaS, three dimensions matter, in this order:

  1. The team: can a new developer ship a feature in a week without breaking something else? This is the constraint you hit first.
  2. The data: does each customer's data stay isolated, and do queries stay fast when a table grows from 10,000 to 50 million rows?
  3. The traffic: can the app absorb a spike without saturating the database?

An architecture that optimizes for traffic at the expense of the team (ten microservices for three developers) fails faster than a well-structured monolith. The goal is to keep a single deployable for as long as possible, while drawing clear boundaries between modules so you can extract one the day a real need shows up.

The reference stack in 2026

LayerDefault choiceAlternativeWhen to switch
FrameworkNext.js 16 (App Router)Vite + React RouterA fully authenticated app with no SEO or server rendering needs
LanguageTypeScript in strict mode——
DatabaseManaged PostgreSQL (Neon, Supabase, RDS…)—Rarely: Postgres covers relational data, JSON, full-text search and simple queues
Data accessDrizzle ORMPrisma, KyselyPrisma if the team prefers its tooling; Kysely for typed SQL without an ORM
ValidationZodValibotWhen client-side bundle size becomes critical
AuthenticationBetter AuthClerk, Auth.jsClerk to outsource the UI and enterprise SSO
PaymentsStripe BillingPaddle, Lemon SqueezyA merchant of record that handles sales tax and VAT for you
Background jobspg-boss or an outbox tableInngest, Trigger.devLong-running workflows with managed retries and dashboards
HostingVercelContainers (Fly.io, Railway, Kubernetes)Cost at high load, networking or data-residency constraints
ObservabilityOpenTelemetry + SentryDatadog, GrafanaData volume or the need for a single tool

Two notes on this table. First, Auth.js has been maintained by the Better Auth team since September 2025, and they recommend Better Auth for new projects. Second, Next.js 16 renamed middleware.ts to proxy.ts, made Turbopack the default bundler and introduced Cache Components ("use cache"): if you are following a 2024 tutorial, some of the APIs have changed.

A modular monolith with Next.js

The classic trap in a Next.js codebase is organizing code by technical type (components/, hooks/, utils/) until nobody knows where the billing logic lives. I organize code by business module, and each module exposes a small surface:

text
src/
  app/
    (marketing)/              public, static, indexable pages
    dashboard/[org]/          the application, one organization per URL
      projects/page.tsx
      settings/billing/page.tsx
    api/webhooks/stripe/route.ts
  modules/
    projects/
      data.ts                 reads and writes (server-only)
      actions.ts              the module's Server Actions
      schema.ts               Zod schemas shared with forms
      components/
    billing/
    members/
  db/
    schema.ts                 Drizzle tables
    client.ts
    migrations/
  lib/
    auth.ts                   session and organization membership
    tenant.ts                 transaction bound to the current organization
proxy.ts
instrumentation.ts

Three rules make this structure pay off:

  • Pages never talk to the database directly. They call modules/*/data.ts, which checks permissions and then reads data. This is the Data Access Layer the Next.js documentation recommends.
  • A module never imports another module's internals. If billing needs members, it goes through an exported function. An ESLint rule (no-restricted-imports) is enough to enforce it.
  • Client components stay at the leaves of the tree. Server Components load data; only interactive pieces (forms, menus, charts) become "use client". You ship less JavaScript, and secrets stay on the server.

Server Actions or Route Handlers?

Server Actions fit mutations triggered by your own UI: forms, buttons, settings. Route Handlers (app/api/.../route.ts) are still required for anything called from outside: webhooks, public APIs, mobile apps, integrations. A Server Action is a public endpoint like any other: it must validate its input and check permissions, exactly like an API route.

Multi-tenant PostgreSQL: isolating customer data

This is the decision that is hardest to change later. There are three strategies:

StrategyIsolationOperational costBest for
Shared database + organization_id + RLSLogical, enforced by PostgreSQLLowThe vast majority of B2B SaaS products
One schema per customerMediumMigrations run N timesA few dozen large customers
One database per customerStrongHigh: connections, migrations and backups multipliedContractual or regulatory requirements

I almost always start with the first one. Its risk is well known: a query that forgets the organization_id filter leaks another customer's data. PostgreSQL row-level security closes that gap at the database level:

sql
create table projects (
  id uuid primary key default uuidv7(),
  organization_id uuid not null references organizations (id),
  name text not null,
  created_at timestamptz not null default now()
);

-- Every query filters by organization: the index starts with it.
create index projects_org_created_idx on projects (organization_id, created_at desc);

alter table projects enable row level security;
-- Without FORCE, the table owner bypasses the policies.
alter table projects force row level security;

create policy tenant_isolation on projects
  using (organization_id = current_setting('app.organization_id', true)::uuid)
  with check (organization_id = current_setting('app.organization_id', true)::uuid);

A few details that make the difference in production:

  • current_setting('app.organization_id', true) returns NULL when the variable is not set. The comparison then fails and the query returns nothing: the system fails closed, not open.
  • uuidv7() is built into PostgreSQL 18. These IDs are time-ordered, which is much kinder to indexes than random v4 UUIDs. On older versions, generate them in the application.
  • The application must connect with a dedicated role that is neither a superuser nor BYPASSRLS. Migrations use a different role.

On the TypeScript side, every data access goes through a transaction that sets the current organization:

ts
// lib/tenant.ts
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/db/client";

type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];

export async function withTenant<T>(
  organizationId: string,
  work: (tx: Transaction) => Promise<T>
): Promise<T> {
  return db.transaction(async (tx) => {
    // The third argument (true) scopes the value to this transaction:
    // nothing leaks into the next query, even behind a pooler.
    await tx.execute(sql`select set_config('app.organization_id', ${organizationId}, true)`);
    return work(tx);
  });
}

This matters with a transaction-mode connection pooler (PgBouncer, Supavisor, Neon's pooled URL): a session-level SET would stay attached to the physical connection and contaminate another customer's query. set_config(..., true) only lives for the duration of the transaction.

Drizzle can also declare policies in the schema (pgPolicy) so they are versioned with migrations. Writing them in raw SQL, as above, is perfectly fine; what matters is that they go through code review like everything else.

A typed, checked data access layer

Row-level security is a safety net, not an authorization model. Whether a user is allowed to read an organization's projects (member, admin, guest) is the application's job:

ts
// modules/projects/data.ts
import "server-only";
import { cache } from "react";
import { desc } from "drizzle-orm";
import { projects } from "@/db/schema";
import { requireMembership } from "@/lib/auth";
import { withTenant } from "@/lib/tenant";

export const listProjects = cache(async (orgSlug: string) => {
  // Reads the session, checks membership and role, redirects otherwise.
  const { organizationId } = await requireMembership(orgSlug, "member");

  return withTenant(organizationId, (tx) =>
    tx.select().from(projects).orderBy(desc(projects.createdAt)).limit(50)
  );
});

React's cache deduplicates the call within a single render: a page and its header can both call listProjects without running the query twice. The server-only import fails the build if this file ever ends up in a client bundle.

For writes, the Server Action validates its input with Zod before touching anything:

ts
// modules/projects/actions.ts
"use server";

import { z } from "zod";
import { refresh } from "next/cache";
import { projects } from "@/db/schema";
import { requireMembership } from "@/lib/auth";
import { withTenant } from "@/lib/tenant";

const createProjectInput = z.object({
  orgSlug: z.string().min(1),
  name: z.string().trim().min(2).max(80),
});

export async function createProject(formData: FormData) {
  const input = createProjectInput.parse({
    orgSlug: formData.get("orgSlug"),
    name: formData.get("name"),
  });
  const { organizationId } = await requireMembership(input.orgSlug, "admin");

  await withTenant(organizationId, (tx) =>
    tx.insert(projects).values({ organizationId, name: input.name })
  );

  // Organization data is not cached: refresh the page instead.
  refresh();
}

The same Zod schema validates the form on the client (with React Hook Form, for example): one definition, two uses, consistent error messages.

Authentication, organizations and roles

The core data model of a B2B SaaS fits in four tables: users, organizations, memberships (user, organization, role) and invitations. Start with three roles (owner, admin, member) and one function that answers "can this member perform this action?". Fine-grained permissions can wait until a customer asks for them.

The most important rule: proxy.ts (formerly middleware.ts) must never be your only check. In March 2025, CVE-2025-29927 let attackers skip Next.js middleware entirely with a single HTTP header. Apps that only checked permissions there were wide open; apps that also checked in their data access layer were not.

proxy.ts is still useful for a fast, optimistic redirect:

ts
// proxy.ts: redirects early, protects nothing on its own
import { NextResponse, type NextRequest } from "next/server";

export default function proxy(request: NextRequest) {
  if (!request.cookies.has("session")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*"] };

The real check (valid session, membership, role) happens in requireMembership, which every function in data.ts and every Server Action calls.

Billing: idempotent Stripe webhooks and entitlements

Two principles prevent most billing incidents.

First principle: subscription state comes from webhooks, not from the payment success page. Users can close the tab before the redirect; the webhook still arrives. Stripe may also deliver the same event more than once, and not necessarily in order. The handler must verify the signature and ignore duplicates:

ts
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { db } from "@/db/client";
import { stripeEvents } from "@/db/schema";
import { applyBillingEvent } from "@/modules/billing/apply-event";

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

export async function POST(request: Request) {
  // Raw body: the signature covers the exact bytes received.
  const payload = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

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

  await db.transaction(async (tx) => {
    const inserted = await tx
      .insert(stripeEvents)
      .values({ id: event.id, type: event.type })
      .onConflictDoNothing()
      .returning({ id: stripeEvents.id });

    if (inserted.length === 0) return; // already processed
    await applyBillingEvent(tx, event);
  });

  return new Response(null, { status: 200 });
}

A webhook has no user session: applyBillingEvent finds the organization from the Stripe customer, then sets app.organization_id in the transaction before writing, just like withTenant.

Second principle: code checks entitlements, not plans. Instead of if (plan === "pro") scattered across twenty files, an entitlements table (or a typed constant) maps each plan to its features and limits. The day marketing introduces a "Business" plan, you change one line of configuration, not twenty conditions.

Background jobs and the outbox pattern

Sending an email, generating a PDF, calling a third-party API or an AI model: anything slow or failure-prone leaves the HTTP request. For small, low-stakes tasks, Next.js after() runs code after the response has been sent. As soon as a task must be retried on failure, you need a real queue.

The trap is writing to the database and then publishing a message: if the process dies in between, the event is lost. The outbox pattern fixes this by writing the event in the same transaction as the business data:

sql
create table outbox (
  id bigint generated always as identity primary key,
  organization_id uuid not null,
  topic text not null,
  payload jsonb not null,
  created_at timestamptz not null default now(),
  processed_at timestamptz
);

-- A worker claims a batch inside a transaction; other workers
-- skip locked rows instead of waiting for them.
select id, topic, payload
from outbox
where processed_at is null
order by id
limit 20
for update skip locked;

The worker processes the batch, sets processed_at, then commits. Since a message can be processed twice after a crash, every handler must be idempotent. Libraries such as pg-boss implement this on top of PostgreSQL; Inngest or Trigger.dev move it to a managed service with retries and a dashboard.

Performance: what actually matters

In a SaaS, slowness rarely comes from React. It comes from the database and the network. In order of impact:

  1. Connection pooling. Serverless functions that each open their own connection saturate PostgreSQL within seconds of a spike. Use a transaction-mode pooler. Depending on the pooler version, you may need to disable prepared statements in the driver.
  2. Indexes that match your queries. Almost every query filters by organization_id, so most composite indexes start with that column. Check with EXPLAIN (ANALYZE, BUFFERS) instead of guessing.
  3. No N+1 queries. A list of 50 projects that triggers 50 queries for their authors is fixed with a join or Drizzle's relational queries.
  4. Cursor pagination (where created_at < $1 order by created_at desc limit 50) rather than offset, which gets slower the deeper you page.
  5. No request waterfalls. Run independent reads in parallel (Promise.all) and wrap slow blocks in <Suspense> so the rest of the page renders immediately.
  6. Caching, last. With cacheComponents: true, Next.js 16 caches nothing implicitly. Keep "use cache" and cacheLife for data shared across customers (pricing, documentation, public pages) and invalidate it with updateTag() after a mutation. Organization data stays dynamic: it is simpler and safer.

Observability, testing and deployment

Observability. An instrumentation.ts file at the root is enough to emit OpenTelemetry traces:

ts
// instrumentation.ts
import { registerOTel } from "@vercel/otel";

export function register() {
  registerOTel({ serviceName: "saas-app" });
}

Add the organization ID to traces and logs: when a customer reports slowness, you filter on their organization instead of digging through every request. Sentry (or an equivalent) captures errors with their context.

Testing. I split the effort like this:

  • unit tests (Vitest) for pure business logic: entitlement checks, billing rules;
  • integration tests against a real PostgreSQL (Testcontainers or one database per CI job), including at least one test that connects as organization A and asserts it sees zero rows from organization B;
  • a few end-to-end tests (Playwright) on the journeys that make money: sign-up, invitation, payment.

Deployment. Migrations are generated (drizzle-kit generate), reviewed in the pull request and applied by CI before the deploy. To change a schema without downtime, use expand/contract: add the new column, deploy code that writes to both, backfill, and only then drop the old one. Per-branch preview environments with their own database branch (Neon supports this natively) let you test a migration before merging it.

Expensive mistakes

  • Splitting into microservices on day one. You pay for distributed complexity (network, transactions, deployments) before you need it.
  • Checking permissions only in proxy.ts. See above: one flaw in that layer and everything is exposed.
  • Connecting as the table owner. Row-level security does not apply to the owner without FORCE ROW LEVEL SECURITY, nor to BYPASSRLS roles.
  • Hard-coding plans. Every commercial change turns into an engineering project.
  • Non-idempotent webhooks. A replayed event credits an account twice, or cancels a subscription that was already reactivated.
  • Slow work inside the request. An email provider timeout blocks the user's sign-up.
  • No correlated traces. Without an organization ID in the logs, every incident starts with an hour of searching.

Production checklist

  • Every business table has an organization_id column, an index that starts with it and an RLS policy with FORCE.
  • The application connects with a role without BYPASSRLS; migrations use a different role.
  • Every read and every Server Action checks the session, the membership and the role.
  • Input is validated with Zod on the server, even if the form already validates it.
  • Webhooks verify their signature and ignore events that were already processed.
  • Emails, exports and external calls go through a queue with retries.
  • The database sits behind a pooler, and the main queries were checked with EXPLAIN ANALYZE.
  • OpenTelemetry traces and errors carry the organization ID.
  • An automated test proves one organization cannot see another one's data.
  • Backups are tested with an actual restore, not just configured.

Frequently asked questions

Is Next.js a good fit for a B2B SaaS?

Yes. In one project, Next.js handles the indexable public pages (home, pricing, documentation), the authenticated application and the APIs (webhooks, integrations). For a fully private application with no SEO or server rendering needs, a Vite SPA remains a lighter alternative.

Drizzle or Prisma for a SaaS on PostgreSQL?

Both work. Drizzle stays close to SQL, which makes row-level security, advanced queries and transaction control easier. Prisma offers very complete tooling and a declarative schema that many teams like. The choice matters less than the discipline: a single data access layer and reviewed migrations.

Do you need microservices for a SaaS to scale?

No. A modular monolith with a well-indexed PostgreSQL database and background jobs handles high load. You extract a service when one module has genuinely different needs (load profile, language, dedicated team), and clean module boundaries make that extraction straightforward.

How do you isolate each customer's data in PostgreSQL?

In most cases: a shared database, an organization_id column on every business table, and row-level security policies that compare that column with a variable set at the start of each transaction with set_config(..., true). A schema or a database per customer is justified by strong contractual requirements.

Server Actions or a REST API for mutations?

Server Actions for mutations triggered by your own UI, Route Handlers for anything called from outside (webhooks, public API, mobile). Either way, validate input and check permissions: a Server Action is a public HTTP endpoint.

Going further

If you are planning a SaaS or a web application on this stack, I can help as a freelancer, from scoping to production: see my React, Next.js and TypeScript development services. If you are hiring, the React and Node.js section of my hiring page explains what I bring to a product team, and the OpsPilot case study shows a project built around PostgreSQL, Redis and observability.

Also worth reading: the Next.js developer portfolio SEO checklist, the AI agent release playbook for adding AI to a SaaS without losing control, and how I built this portfolio with Next.js.

Share this article

XLinkedIn

Open to work

Hiring, or planning a web project?

I'm available for a permanent role and for freelance missions (React, Next.js, Node.js, automation), in Paris or remotely. This blog shows how I scope, build and secure my work.

All articles
    • AI Agents
    • Production
    • Automation

    Deploy an AI Agent to Production: A Release Playbook

    A practical release playbook for moving an AI agent from proof of concept to controlled production workflow with permissions, evaluations, observability, go/no-go checks, and rollback.

    7 min read

    • Next.js
    • React
    • TypeScript

    How I Built My Portfolio with Next.js and Vercel

    A deep dive into building a modern, performant, and SEO-optimized developer portfolio using Next.js, TypeScript, Tailwind CSS, and Vercel: architecture decisions, performance optimizations, and best practices.

    6 min read