Skip to content

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.

The setup has three layers:

  1. Proxy reads the tenant ID from a verified JWT and the tenant slug from the subdomain, then forwards them to downstream handlers
  2. Server components and API routes read the tenant ID from headers and use Stratum to resolve config and permissions
  3. 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 RLS
npx create-next-app@latest my-saas --typescript --app --src-dir
cd my-saas
npm install @stratum-hq/lib @stratum-hq/db-adapters pg @prisma/client jose
npm install -D prisma @types/pg

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);

The proxy resolves the tenant in two ways:

  • A bearer token gives the tenant ID. The proxy verifies the token with jose and reads its tenant_id claim.
  • 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/lib/tenant-headers.ts
// 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.

src/proxy.ts
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).*)",
],
};

Create a shared module that initializes Stratum and provides tenant-scoped database access.

src/lib/stratum.ts
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 read the tenant context from the request headers with Next.js’s headers() function.

src/app/dashboard/page.tsx
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 follow the same pattern: read the tenant ID from headers, create a scoped Prisma client, run queries.

src/app/api/orders/route.ts
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 });
}

Use Stratum’s config inheritance to control feature flags, limits, and themes per tenant:

src/app/settings/page.tsx
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>
);
}

For local development, you have two options.

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 claim
TOKEN_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 tenant
curl http://localhost:3000/api/orders -H "Authorization: Bearer $TOKEN_A"
# Test as Globex tenant
curl http://localhost:3000/api/orders -H "Authorization: Bearer $TOKEN_B"

Add entries to /etc/hosts:

127.0.0.1 acme.localhost
127.0.0.1 globex.localhost

Set 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.

For production with real subdomains:

  1. DNS: Add a wildcard A record: *.yourapp.com pointing to your server
  2. TLS: Use a wildcard certificate for *.yourapp.com (Let’s Encrypt supports this via DNS-01 challenge)
  3. Proxy: Set ROOT_DOMAIN to yourapp.com. The proxy reads the subdomain from the host header
// In proxy.ts, add domain validation for production
const 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 });
}

Instead of setting this up manually, use the Stratum scaffolding tools:

npx @stratum-hq/create my-saas --template nextjs
cd my-saas
npx @stratum-hq/cli init

This 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.

Skip the manual setup. The Stack Wizard configures your database, ORM, and framework, then gives you a single command to scaffold the whole project.