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