Next.js multi-tenant SaaS
This guide walks through building a multi-tenant Next.js application where each tenant gets a subdomain (e.g., acme.yourapp.com). A Next.js proxy resolves the tenant. Server components read the result from a request header that only the proxy sets. PostgreSQL RLS enforces data isolation at the database level.
Architecture overview
Section titled “Architecture overview”The setup has three layers:
- Proxy reads the tenant ID from a verified JWT and the tenant slug from the subdomain, then forwards them to downstream handlers
- Server components and API routes read the tenant ID from headers and use Stratum to resolve config and permissions
- Database queries run through Stratum’s
prismaWithTenant()wrapper, which sets the PostgreSQL session variable for RLS
Request → Proxy (resolve tenant) → Server Component / API Route → prismaWithTenant(prisma) → PostgreSQL RLSProject setup
Section titled “Project setup”1. Create the Next.js app
Section titled “1. Create the Next.js app”npx create-next-app@latest my-saas --typescript --app --src-dircd my-saas2. Install dependencies
Section titled “2. Install dependencies”npm install @stratum-hq/lib @stratum-hq/db-adapters pg @prisma/client josenpm install -D prisma @types/pg3. Database setup
Section titled “3. Database setup”Start PostgreSQL and create tables with RLS. See the Prisma + RLS guide for the full Docker and SQL setup. The short version:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";CREATE EXTENSION IF NOT EXISTS "ltree";
CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID NOT NULL, product TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, total NUMERIC(10,2) NOT NULL, created_at TIMESTAMPTZ DEFAULT now());
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);Tenant resolution proxy
Section titled “Tenant resolution proxy”The proxy resolves the tenant in two ways:
- A bearer token gives the tenant ID. The proxy verifies the token with
joseand reads itstenant_idclaim. - A subdomain gives the tenant slug. A subdomain only selects which tenant’s public page to show. It does not prove that the caller belongs to that tenant.
The proxy forwards the result in two request headers. First, define their names in one module:
// src/proxy.ts deletes any client copy of these headers before it sets// them, so a handler that reads them sees only values that the proxy set.export const TENANT_ID_HEADER = "x-stratum-tenant-id";export const TENANT_SLUG_HEADER = "x-stratum-tenant-slug";Then create src/proxy.ts. With the --src-dir layout, Next.js reads the proxy file from src/, next to app/. Next.js 16 runs the proxy on the Node.js runtime.
import { NextRequest, NextResponse } from "next/server";import { jwtVerify } from "jose";import { TENANT_ID_HEADER, TENANT_SLUG_HEADER } from "./lib/tenant-headers";
/** Return the tenant_id claim of a token that verifies with JWT_SECRET, or null. */async function verifiedTenantId(token: string): Promise<string | null> { const secret = process.env.JWT_SECRET; if (!secret) { throw new Error("JWT_SECRET is required to verify bearer tokens."); } try { const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), { algorithms: ["HS256"], }); return typeof payload.tenant_id === "string" ? payload.tenant_id : null; } catch { return null; }}
export async function proxy(request: NextRequest) { // Only this proxy may set the tenant headers. const requestHeaders = new Headers(request.headers); requestHeaders.delete(TENANT_ID_HEADER); requestHeaders.delete(TENANT_SLUG_HEADER);
// 1. Bearer token: the verified tenant_id claim. // A token that does not verify is rejected, never ignored. const authorization = request.headers.get("authorization"); if (authorization?.startsWith("Bearer ")) { const tenantId = await verifiedTenantId(authorization.slice("Bearer ".length)); if (!tenantId) { return NextResponse.json({ error: "Invalid bearer token" }, { status: 401 }); } requestHeaders.set(TENANT_ID_HEADER, tenantId); }
// 2. Subdomain: "acme" from "acme.yourapp.com". Strip the port for local development. const hostname = (request.headers.get("host") ?? "").split(":")[0]; const rootDomain = process.env.ROOT_DOMAIN ?? "yourapp.com"; if (hostname.endsWith(`.${rootDomain}`)) { const subdomain = hostname.slice(0, hostname.length - rootDomain.length - 1); if (subdomain && subdomain !== "www") { requestHeaders.set(TENANT_SLUG_HEADER, subdomain); } }
return NextResponse.next({ request: { headers: requestHeaders }, });}
export const config = { matcher: [ // Match all paths except static files "/((?!_next/static|_next/image|favicon.ico).*)", ],};Stratum client library
Section titled “Stratum client library”Create a shared module that initializes Stratum and provides tenant-scoped database access.
import { Pool } from "pg";import { PrismaClient } from "@prisma/client";import { Stratum } from "@stratum-hq/lib";import { prismaWithTenant } from "@stratum-hq/db-adapters";import { TENANT_ID_HEADER } from "./tenant-headers";
const pool = new Pool({ connectionString: process.env.DATABASE_URL,});
const prisma = new PrismaClient();
export const stratum = new Stratum({ pool });
export function getTenantPrisma(tenantId: string) { return prismaWithTenant(prisma, () => tenantId, pool);}
// The tenant ID comes only from a verified token. A subdomain alone does not// give access to tenant data.export async function getTenantFromHeaders(headers: Headers) { const tenantId = headers.get(TENANT_ID_HEADER); if (!tenantId) return null;
const [config, permissions] = await Promise.all([ stratum.resolveConfig(tenantId), stratum.resolvePermissions(tenantId), ]);
return { tenant_id: tenantId, resolved_config: config, resolved_permissions: permissions, };}Server components
Section titled “Server components”Server components read the tenant context from the request headers with Next.js’s headers() function.
import { headers } from "next/headers";import { getTenantFromHeaders, getTenantPrisma } from "@/lib/stratum";
export default async function DashboardPage() { const headerList = await headers(); const tenant = await getTenantFromHeaders(headerList);
if (!tenant) { return <div>No tenant context. Send a bearer token with a tenant_id claim.</div>; }
const tenantPrisma = getTenantPrisma(tenant.tenant_id); const orders = await tenantPrisma.order.findMany({ orderBy: { createdAt: "desc" }, take: 10, });
return ( <main> <h1>Dashboard for {tenant.tenant_id}</h1> <p>Theme: {tenant.resolved_config?.theme?.value ?? "default"}</p> <h2>Recent Orders</h2> <ul> {orders.map((order) => ( <li key={order.id}> {order.product} x{order.quantity} = ${order.total} </li> ))} </ul> </main> );}API routes
Section titled “API routes”API routes follow the same pattern: read the tenant ID from headers, create a scoped Prisma client, run queries.
import { headers } from "next/headers";import { NextResponse } from "next/server";import { getTenantPrisma } from "@/lib/stratum";import { TENANT_ID_HEADER } from "@/lib/tenant-headers";
export async function GET() { const headerList = await headers(); const tenantId = headerList.get(TENANT_ID_HEADER); if (!tenantId) { return NextResponse.json({ error: "Missing bearer token" }, { status: 401 }); }
const tenantPrisma = getTenantPrisma(tenantId); const orders = await tenantPrisma.order.findMany({ orderBy: { createdAt: "desc" }, });
return NextResponse.json(orders);}
export async function POST(request: Request) { const headerList = await headers(); const tenantId = headerList.get(TENANT_ID_HEADER); if (!tenantId) { return NextResponse.json({ error: "Missing bearer token" }, { status: 401 }); }
const body = await request.json(); const tenantPrisma = getTenantPrisma(tenantId); const order = await tenantPrisma.order.create({ data: { tenantId, product: body.product, quantity: body.quantity, total: body.total, }, });
return NextResponse.json(order, { status: 201 });}Tenant-aware config
Section titled “Tenant-aware config”Use Stratum’s config inheritance to control feature flags, limits, and themes per tenant:
import { headers } from "next/headers";import { getTenantFromHeaders } from "@/lib/stratum";
export default async function SettingsPage() { const headerList = await headers(); const tenant = await getTenantFromHeaders(headerList);
if (!tenant) { return <div>No tenant context.</div>; }
const config = tenant.resolved_config;
return ( <main> <h1>Settings</h1> <dl> <dt>Max Users</dt> <dd>{config?.max_users?.value ?? "Unlimited"}</dd> <dt>Theme</dt> <dd>{config?.theme?.value ?? "default"}</dd> <dt>SIEM Enabled</dt> <dd>{config?.["features.siem"]?.value ? "Yes" : "No"}</dd> </dl> </main> );}Local development
Section titled “Local development”For local development, you have two options.
Option 1: bearer token (no DNS changes)
Section titled “Option 1: bearer token (no DNS changes)”Start the server with JWT_SECRET set, for example to the output of openssl rand -hex 32. Your identity provider issues the tokens in a real application. For local tests, sign one token for each tenant with the same secret:
# Sign a token that carries the tenant ID in its tenant_id claimTOKEN_A=$(node --input-type=module -e 'import { SignJWT } from "jose"; console.log(await new SignJWT({ tenant_id: process.argv[1] }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1h").sign(new TextEncoder().encode(process.env.JWT_SECRET)))' "$TENANT_A_ID")TOKEN_B=$(node --input-type=module -e 'import { SignJWT } from "jose"; console.log(await new SignJWT({ tenant_id: process.argv[1] }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1h").sign(new TextEncoder().encode(process.env.JWT_SECRET)))' "$TENANT_B_ID")
# Test as Acme tenantcurl http://localhost:3000/api/orders -H "Authorization: Bearer $TOKEN_A"
# Test as Globex tenantcurl http://localhost:3000/api/orders -H "Authorization: Bearer $TOKEN_B"Option 2: /etc/hosts (subdomains)
Section titled “Option 2: /etc/hosts (subdomains)”Add entries to /etc/hosts:
127.0.0.1 acme.localhost127.0.0.1 globex.localhostSet ROOT_DOMAIN=localhost. Then http://acme.localhost:3000 sets the tenant slug acme. A public page can resolve the slug with stratum.getTenantBySlug(). Pages that show tenant data still need a bearer token.
Production deployment
Section titled “Production deployment”For production with real subdomains:
- DNS: Add a wildcard A record:
*.yourapp.compointing to your server - TLS: Use a wildcard certificate for
*.yourapp.com(Let’s Encrypt supports this via DNS-01 challenge) - Proxy: Set
ROOT_DOMAINtoyourapp.com. The proxy reads the subdomain from thehostheader
// In proxy.ts, add domain validation for productionconst allowedBaseDomains = ["yourapp.com", "localhost"];const parts = hostname.split(".");const baseDomain = parts.slice(-2).join(".");
if (!allowedBaseDomains.includes(baseDomain)) { return new NextResponse("Invalid domain", { status: 400 });}Scaffold with Stratum CLI
Section titled “Scaffold with Stratum CLI”Instead of setting this up manually, use the Stratum scaffolding tools:
npx @stratum-hq/create my-saas --template nextjscd my-saasnpx @stratum-hq/cli initThis generates the proxy (middleware.ts on Next.js 15), Stratum client library, API route helpers, and example server components. The init command detects Next.js and scaffolds the right files.
Generate a ready-to-run project
Section titled “Generate a ready-to-run project”Skip the manual setup. The Stack Wizard configures your database, ORM, and framework, then gives you a single command to scaffold the whole project.
Next steps
Section titled “Next steps”- See the Prisma + RLS guide for detailed database setup
- See the Drizzle guide if you prefer Drizzle over Prisma
- Learn about isolation strategies to pick between RLS, schema-per-tenant, and database-per-tenant
- Build an Express API if you need a standalone backend
- Read about config inheritance for per-tenant feature flags