AboutBlogContact
Backend EngineeringApril 30, 2026 9 min read 6

Building a Multi-Tenant SaaS with Next.js and PostgreSQL in 2026

AunimedaAunimeda
📋 Table of Contents

Building a Multi-Tenant SaaS with Next.js and PostgreSQL in 2026

Multi-tenancy is the foundational architecture decision for any SaaS product. Get it wrong and you'll either rewrite it two years in or live with performance and security trade-offs that cap your growth. Here's the complete picture.


Three Models, Three Trade-offs

1. Database per Tenant

Each customer gets their own PostgreSQL database.

tenant_acme → postgres://db/acme_db
tenant_beta → postgres://db/beta_db
tenant_corp → postgres://db/corp_db

When to use: Enterprise SaaS where customers have compliance requirements (healthcare, finance, legal), need data residency in specific regions, or require custom schemas per client.

Downsides: Operational complexity scales with customer count. 1,000 customers = 1,000 databases. Connection pooling becomes critical. Schema migrations need to run across all databases.

2. Schema per Tenant

All tenants in one database, each in their own PostgreSQL schema.

-- Each tenant gets their own schema
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_beta;

-- Tables are namespaced
tenant_acme.users
tenant_acme.orders
tenant_beta.users
tenant_beta.orders

When to use: Mid-market SaaS needing isolation without the overhead of separate databases. Good balance of isolation and operational simplicity.

Downside: Cross-tenant analytics queries are awkward. Schema migrations still need to run per schema.

3. Row-Level Security (RLS) — Recommended for Most SaaS

All tenants share the same tables. PostgreSQL's Row Level Security enforces isolation at the database level.

-- All data in one table
CREATE TABLE orders (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(id),
  user_id     uuid NOT NULL,
  total_cents integer NOT NULL,
  created_at  timestamptz DEFAULT now()
);

-- PostgreSQL enforces: each connection can only see its own tenant's rows
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY orders_tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

When to use: Most B2B SaaS products. Simplest to operate, easiest migrations, scales to thousands of tenants with no operational burden.

Downside: Noisy neighbor problem (one large tenant's queries slow down others). Mitigated with connection pooling (PgBouncer) and read replicas.


Implementation: RLS with Next.js

This is the architecture I'd choose for a new SaaS product today.

Database Schema

-- Tenants table
CREATE TABLE tenants (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug       text UNIQUE NOT NULL,     -- subdomain: acme.yourapp.com
  name       text NOT NULL,
  plan       text NOT NULL DEFAULT 'free',
  created_at timestamptz DEFAULT now()
);

-- Users belong to a tenant
CREATE TABLE users (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       text NOT NULL,
  name        text NOT NULL,
  role        text NOT NULL DEFAULT 'member', -- 'owner', 'admin', 'member'
  created_at  timestamptz DEFAULT now(),
  UNIQUE(tenant_id, email)
);

-- RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Example: projects table
CREATE TABLE projects (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        text NOT NULL,
  created_by  uuid NOT NULL REFERENCES users(id),
  created_at  timestamptz DEFAULT now()
);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Indexes (always include tenant_id first for performance)
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id, created_at DESC);

Tenant Resolution Middleware

// middleware.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
import { getTenantBySubdomain } from '@/lib/db/tenants';

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') ?? '';
  
  // Extract subdomain: acme.yourapp.com → 'acme'
  const subdomain = hostname.split('.')[0];
  const isMainDomain = ['www', 'app', 'yourapp'].includes(subdomain);

  if (isMainDomain) {
    // Main marketing site — no tenant context needed
    return NextResponse.next();
  }

  // Resolve tenant from subdomain
  const tenant = await getTenantBySubdomain(subdomain);
  
  if (!tenant) {
    return NextResponse.redirect(new URL('/not-found', request.url));
  }

  // Pass tenant ID to the request via header
  const response = NextResponse.next();
  response.headers.set('X-Tenant-Id', tenant.id);
  response.headers.set('X-Tenant-Slug', tenant.slug);
  
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Database Client with RLS

// lib/db/client.ts
import { Pool, PoolClient } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
});

// Wrap every query in a transaction that sets tenant context
export async function withTenant<T>(
  tenantId: string,
  callback: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    
    // Set the tenant context — RLS policies use this
    await client.query(
      `SELECT set_config('app.current_tenant_id', $1, true)`,
      [tenantId]
    );
    
    const result = await callback(client);
    
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

// Usage:
// const projects = await withTenant(tenantId, async (db) => {
//   const { rows } = await db.query('SELECT * FROM projects ORDER BY created_at DESC');
//   return rows;
// });

Server Actions with Tenant Context

// lib/actions/projects.ts
'use server';

import { headers } from 'next/headers';
import { withTenant } from '@/lib/db/client';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';

function getTenantId(): string {
  const tenantId = headers().get('X-Tenant-Id');
  if (!tenantId) throw new Error('No tenant context');
  return tenantId;
}

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
});

export async function createProject(formData: FormData) {
  const tenantId = getTenantId();
  
  const parsed = CreateProjectSchema.safeParse({
    name: formData.get('name'),
  });
  
  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }

  const project = await withTenant(tenantId, async (db) => {
    const { rows } = await db.query(
      `INSERT INTO projects (tenant_id, name, created_by)
       VALUES ($1, $2, $3)
       RETURNING *`,
      [tenantId, parsed.data.name, getCurrentUserId()]
    );
    return rows[0];
  });

  revalidatePath('/projects');
  return { data: project };
}

export async function getProjects() {
  const tenantId = getTenantId();
  
  return withTenant(tenantId, async (db) => {
    const { rows } = await db.query(
      `SELECT p.*, u.name as created_by_name
       FROM projects p
       JOIN users u ON u.id = p.created_by
       ORDER BY p.created_at DESC`
    );
    return rows;
  });
}

Authentication: Multi-Tenant Sessions

// lib/auth/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

interface Session {
  userId: string;
  tenantId: string;
  role: 'owner' | 'admin' | 'member';
}

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function createSession(session: Session): Promise<string> {
  return new SignJWT(session)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret);
}

export async function getSession(): Promise<Session | null> {
  const token = cookies().get('session')?.value;
  if (!token) return null;

  try {
    const { payload } = await jwtVerify(token, secret);
    return payload as unknown as Session;
  } catch {
    return null;
  }
}

// Login action
export async function login(email: string, password: string, tenantSlug: string) {
  const tenant = await getTenantBySlug(tenantSlug);
  if (!tenant) throw new Error('Tenant not found');

  const user = await getUserByEmail(email, tenant.id);
  if (!user || !await verifyPassword(password, user.password_hash)) {
    throw new Error('Invalid credentials');
  }

  const token = await createSession({
    userId: user.id,
    tenantId: tenant.id,
    role: user.role,
  });

  cookies().set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });
}

Plan Limits and Feature Flags

// lib/plans.ts
export const PLANS = {
  free: {
    maxUsers: 3,
    maxProjects: 5,
    features: ['basic_reporting'],
  },
  starter: {
    maxUsers: 10,
    maxProjects: 50,
    features: ['basic_reporting', 'api_access'],
  },
  pro: {
    maxUsers: 100,
    maxProjects: Infinity,
    features: ['basic_reporting', 'api_access', 'sso', 'audit_log'],
  },
} as const;

type Plan = keyof typeof PLANS;

export function hasFeature(plan: Plan, feature: string): boolean {
  return (PLANS[plan].features as readonly string[]).includes(feature);
}

// In a Server Action:
export async function inviteUser(email: string) {
  const session = await getSession();
  const tenant = await getTenantById(session!.tenantId);
  const plan = PLANS[tenant.plan as Plan];

  const currentUserCount = await getUserCount(tenant.id);
  if (currentUserCount >= plan.maxUsers) {
    return { error: `Your ${tenant.plan} plan allows ${plan.maxUsers} users. Upgrade to add more.` };
  }

  // ... proceed with invite
}

Migration Strategy

Running schema migrations across thousands of tenants is painful with schema-per-tenant. With RLS and shared tables, it's just normal migrations.

# Use any migration tool — here with node-pg-migrate
npm install node-pg-migrate

# Create a migration
npx node-pg-migrate create add-status-to-projects

# Run migrations
DATABASE_URL=postgres://... npx node-pg-migrate up
// migrations/1714500000000_add-status-to-projects.js
exports.up = (pgm) => {
  pgm.addColumn('projects', {
    status: {
      type: 'text',
      notNull: true,
      default: 'active',
      check: "status IN ('active', 'archived', 'deleted')",
    },
  });
  pgm.createIndex('projects', ['tenant_id', 'status']);
};

exports.down = (pgm) => {
  pgm.dropColumn('projects', 'status');
};

Subdomain Setup

For acme.yourapp.com → tenant "acme":

DNS: Wildcard CNAME *.yourapp.com → your load balancer or Vercel.

Vercel: Add *.yourapp.com as a custom domain in project settings.

Next.js config:

// next.config.js
module.exports = {
  async rewrites() {
    return [
      // Allow subdomain routing in development
      {
        source: '/:path*',
        has: [{ type: 'host', value: '(?<tenant>.+).localhost' }],
        destination: '/:path*',
      },
    ];
  },
};

Billing with Stripe

// lib/billing.ts
import Stripe from 'stripe';

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

export async function createCheckoutSession(tenantId: string, plan: string) {
  const tenant = await getTenantById(tenantId);

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{
      price: PLAN_PRICE_IDS[plan],
      quantity: 1,
    }],
    success_url: `https://${tenant.slug}.yourapp.com/settings/billing?success=true`,
    cancel_url: `https://${tenant.slug}.yourapp.com/settings/billing`,
    metadata: { tenantId },
    customer_email: tenant.owner_email,
  });

  return session.url;
}

// Stripe webhook handler
export async function handleStripeWebhook(body: string, signature: string) {
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      await updateTenantPlan(session.metadata!.tenantId, 'pro');
      break;
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object;
      // Find tenant by Stripe customer ID and downgrade
      await downgradeTenantToFree(sub.customer as string);
      break;
    }
  }
}

Performance at Scale

Connection pooling. At 1,000 tenants, you need PgBouncer in front of PostgreSQL. Without it, 1,000 concurrent users = 1,000 database connections = PostgreSQL melts.

Read replicas. Route read-heavy queries (reports, dashboards) to a read replica. PostgreSQL streaming replication is standard.

Tenant-aware caching. Cache keys must include tenant_id:

const cacheKey = `tenant:${tenantId}:projects:list`;
const cached = await redis.get(cacheKey);

Background jobs. Isolate tenant workloads. Use a queue (BullMQ, Inngest) with tenant ID in job metadata:

await queue.add('generate-report', {
  tenantId,
  reportType: 'monthly',
}, {
  priority: tenant.plan === 'pro' ? 1 : 5, // Pro tenants get priority
});

Aunimeda designs and builds multi-tenant SaaS products — from architecture decisions through to production deployment and scaling.

Contact us to discuss your SaaS project. See also: Custom Software Development, Web Development, DevOps

Read Also

PostgreSQL Performance Optimization: The Practical Guide for 2026aunimeda
Backend Engineering

PostgreSQL Performance Optimization: The Practical Guide for 2026

Slow queries, missing indexes, N+1 problems, and connection pool exhaustion account for 90% of PostgreSQL performance issues. Here's how to diagnose and fix each one with real queries.

Node.js + TypeScript: Building a Production REST API from Scratch in 2026aunimeda
Backend Engineering

Node.js + TypeScript: Building a Production REST API from Scratch in 2026

A complete guide to building a production-ready REST API with Node.js and TypeScript — authentication, validation, error handling, rate limiting, logging, and deployment. No shortcuts.

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluffaunimeda
Backend Engineering

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluff

Clean Architecture sounds great in theory. In practice, most implementations add complexity without benefit. This guide shows the pattern that actually works in Node.js - dependency inversion, use cases, and repository pattern with real, runnable code.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles