The fastest, most defensible way to architect a multi-tenant SaaS on Next.js and Supabase is a single shared Postgres database with Row-Level Security for isolation, JWT tenant claims to keep those policies index-friendly, Next.js's proxy layer for subdomain and custom-domain routing, and Supavisor in transaction mode for serverless connection pooling. This is the same pattern behind PosifyHub, our production multi-tenant POS and inventory SaaS — and it holds up whether you have three tenants or three thousand.
Key takeaways:
- Shared-schema RLS wins for roughly 95% of B2B SaaS — separate schemas and separate databases exist for specific compliance and noisy-neighbor cases, not as a "more secure by default" upgrade.
- Index every column your RLS policy touches. An un-indexed
tenant_iddoesn't just slow queries — it turns your security boundary into your bottleneck. middleware.tsis deprecated in Next.js 16, renamed toproxy.ts— and Vercel now explicitly recommends keeping authentication out of it.- Supavisor's transaction-mode pooler (port 6543), not session mode, is the correct default for Next.js API routes and Server Components on serverless infrastructure.
- Custom domains are provisioned through the Vercel Domains API, not a support ticket — SSL issuance and renewal are automatic once ownership is verified.
On this page
- 1. Choosing a Tenant Isolation Model: Shared Schema vs. Separate Schema vs. Separate Database
- 2. Implementing Row-Level Security (RLS) in Supabase Without Killing Performance
- 3. Routing Tenants in Next.js: Subdomains, Custom Domains, and the proxy.ts Migration
- 4. Connection Pooling for Bursty B2B Workloads with Supavisor
- 5. When Not to Use Shared-Schema Multi-Tenancy
- 6. Frequently Asked Questions
- 7. Strategic Takeaways
1. Choosing a Tenant Isolation Model: Shared Schema vs. Separate Schema vs. Separate Database
Three architectures compete for multi-tenant data, and for the overwhelming majority of B2B SaaS products, shared schema with Postgres Row-Level Security is the correct default — not the budget compromise it's sometimes framed as.
| Isolation Model | Implementation | Security Level | Maintenance Overhead | Cost Efficiency |
|---|---|---|---|---|
| Shared Database, Shared Schema (RLS) | Tenancy isolated via row policies in a single schema. | High (when indexed correctly) | Very Low | Excellent |
| Shared Database, Separate Schemas | A PostgreSQL schema per tenant in a single database. | Very High | Medium | Good |
| Separate Databases | A dedicated physical database (or project) per tenant. | Absolute | High | Poor |
The decision framework: pick shared-schema RLS the moment you have more than one paying customer — it's the default, not a stepping stone. Move to schema-per-tenant only when a specific enterprise contract demands physical separation for a subset of accounts. Reserve separate databases for cases where regulation, not preference, forces the decision (more on this in Section 5).
Every tenant-scoped table in this model needs a tenant_id column, every policy needs to filter on it, and — this is the part most tutorials skip — that column needs an index from day one.
2. Implementing Row-Level Security (RLS) in Supabase Without Killing Performance
Every tenant-scoped table needs a tenant_id column, RLS enabled, and an index-friendly policy — skip the third part and RLS becomes your slowest query, not your security boundary.
-- Create tenants table to hold organization profiles
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
subdomain TEXT UNIQUE NOT NULL,
custom_domain TEXT UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Associate users with tenants (Many-to-Many or One-to-Many junction)
CREATE TABLE public.tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
role TEXT CHECK (role IN ('owner', 'admin', 'member')) DEFAULT 'member' NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
UNIQUE(tenant_id, user_id)
);
-- Sample tenant-scoped data table
CREATE TABLE public.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Index every tenant_id an RLS policy will touch — this is what keeps the
-- policy a fast index scan instead of a sequential scan as tables grow.
CREATE INDEX idx_documents_tenant_id ON public.documents (tenant_id);
CREATE INDEX idx_tenant_users_tenant_id ON public.tenant_users (tenant_id);
The RLS Trap: Why Subquery-Based Policies Don't Scale
By default, developers write RLS policies that query other tables, like this:
USING (auth.uid() IN (SELECT user_id FROM tenant_users WHERE tenant_id = documents.tenant_id))
RLS is not a checkbox — it's a query planner problem. Every row evaluation executes that subquery, which produces O(N) complexity on table scans. On a table with 100,000+ rows, this degrades fast, and the failure mode is subtle: it works fine in staging with sample data, then quietly turns every dashboard load into a full scan once a real customer's data volume shows up in production. We've seen an early version of exactly this pattern on PosifyHub's multi-tenant rollout. (If you have a real before/after query-time number from your own Supabase performance dashboard, drop it here — a concrete millisecond figure is the single most credible line you can add to this section.)
The Fix: JWT Custom Claims and an Indexable Helper Function
Use Supabase JWT custom claims or a lightweight STABLE SQL function that reads the tenant ID directly from the JWT, instead of joining across tables at query time:
-- Enable RLS on all tenant-scoped tables
ALTER TABLE public.tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- Dynamic helper function to fetch active tenant context
CREATE OR REPLACE FUNCTION public.current_tenant_id()
RETURNS UUID AS $$
-- Reads from database request config set during server client init
SELECT nullif(current_setting('request.jwt.claims', true)::json->>'tenant_id', '')::uuid;
$$ LANGUAGE sql STABLE;
-- RLS Policy for Documents
CREATE POLICY "Users can access documents belonging to their tenant"
ON public.documents
FOR ALL
TO authenticated
USING (
tenant_id = public.current_tenant_id()
);
STABLE matters here: it tells Postgres's planner the function's result won't change within a single statement, which makes it safe to cache the tenant lookup once per query instead of re-evaluating it per row.
3. Routing Tenants in Next.js: Subdomains, Custom Domains, and the proxy.ts Migration
Tenant routing in Next.js changed meaningfully in version 16: the middleware.ts convention many tutorials still teach is deprecated, renamed to proxy.ts, and now carries an explicit warning against doing authentication inside it (Next.js Blog, 2025).
Subdomain Routing with proxy.ts (Next.js 16)
If a user visits acme.your-platform.com or a custom domain, your app needs to resolve which tenant that request belongs to before it renders anything — and rewrite the path accordingly.
// proxy.ts — replaces middleware.ts in Next.js 16+
// The export must be a named "proxy" function, not "middleware"
import { NextRequest, NextResponse } from "next/server";
export function proxy(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get("host") || "";
// Exclude system static paths or API routes
if (
url.pathname.startsWith("/_next") ||
url.pathname.startsWith("/api") ||
url.pathname.includes(".")
) {
return NextResponse.next();
}
const isLocalhost = hostname.includes("localhost");
const baseDomain = isLocalhost ? "localhost:3000" : "yourplatform.com";
let subdomain = "";
if (hostname.endsWith(baseDomain)) {
subdomain = hostname.replace(`.${baseDomain}`, "").replace(baseDomain, "");
}
// Custom domains won't match baseDomain at all — resolve those against
// your tenants table (or an edge-cached lookup) before rewriting.
if (subdomain && subdomain !== "www") {
url.pathname = `/${subdomain}${url.pathname}`;
const response = NextResponse.rewrite(url);
response.headers.set("x-resolved-tenant", subdomain);
return response;
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)"],
};
In multi-tenant systems, the routing layer should route — not authenticate. proxy.ts runs on the Node.js runtime by default and is meant to stay a thin layer: resolve the hostname, rewrite the path, pass the tenant along in a header. Session validation and permission checks belong in a Server Component or Route Handler downstream. This isn't just a style preference — it follows Vercel's own post-CVE-2025-29927 guidance, after a disclosed middleware-based auth bypass made "middleware as your only auth gate" an unsafe pattern.
Still shipping on Next.js 15 or earlier? Nothing above changes conceptually — keep the file named middleware.ts and the function named middleware. When you're ready to upgrade, Vercel ships a codemod for the rename: npx @next/codemod@latest rename-middleware-to-proxy . (Next.js Docs).
Provisioning Custom Domains Programmatically with the Vercel Domains API
Subdomains get you started; custom domains are what enterprise buyers expect before they'll put your platform in front of their own customers. Do this through code, not a manual DNS ticket:
import { Vercel } from "@vercel/sdk";
const vercel = new Vercel({ bearerToken: process.env.VERCEL_TOKEN });
// 1. Add the tenant's domain to your platform project
await vercel.projects.addProjectDomain({
idOrName: "your-platform-project",
requestBody: { name: "custom-domain.com" },
});
// 2. If Vercel can't auto-verify ownership, it returns a TXT record —
// have the tenant add it to their DNS, then re-check:
await vercel.projects.verifyProjectDomain({
idOrName: "your-platform-project",
domain: "custom-domain.com",
});
// 3. Once verified, Vercel issues and auto-renews the SSL certificate.
// No certbot, no manual renewal cron job.
Custom domains are a trust signal for enterprise buyers — don't ship your SaaS without a path to them. The exact request/response shape lives in Vercel's Domains API reference (Vercel Docs, 2026); the workflow above is the whole loop: add, verify, done.
4. Connection Pooling for Bursty B2B Workloads with Supavisor
Supabase's connection pooler, Supavisor, isn't optional infrastructure for a serverless Next.js app — without it in transaction mode, one enterprise tenant's traffic spike can exhaust every connection slot and take down every other tenant on the platform with it.
B2B usage is bursty by nature — concentrated in business hours, spiking around month-end reporting or seasonal demand. Serverless functions open and close database connections far more often than a long-running server would, and Postgres has a hard ceiling on concurrent connections. For Next.js API routes and Server Components, connect through Supavisor's transaction-mode pooler on port 6543, not the direct connection or session-mode pooler on port 5432 (Supabase Docs, 2026). Transaction mode hands a backend connection to a client only for the duration of one query, then returns it to the pool — exactly the lifecycle a serverless function needs.
Connection pooling isn't an optimization you add later; on serverless, it's the difference between a demo and an outage. Two practical guardrails worth setting on day one:
- If you're also using Supabase's auto-generated REST API (PostgREST), keep your own pool size under roughly 40% of the total — PostgREST maintains its own internal pooler and needs headroom.
- Transaction-mode pooling doesn't support prepared statements. If your ORM defaults to them, disable that setting explicitly rather than discovering it in a production error log.
5. When Not to Use Shared-Schema Multi-Tenancy
Shared-schema RLS is the right default, not a universal one — walk away from it the moment compliance or contractual risk, rather than cost, is driving the decision.
- Hard data-residency requirements. If an enterprise contract or regulation (healthcare BAAs, EU data-residency clauses) requires a specific tenant's data to physically live in a given region or on infrastructure no other tenant touches, move that tenant to a separate database or project.
- A single "whale" tenant with unpredictable load. One enterprise account running heavy analytics queries can starve every other tenant sharing the same connection pool — the classic noisy-neighbor problem. A dedicated database or read replica isolates the blast radius.
- Contractual right-to-audit or bring-your-own-database clauses. Some enterprise security reviews require infrastructure a customer can independently audit or host. That's a separate-database conversation, not an RLS-policy conversation.
- A shared blast radius you're not ready to own. In shared schema, one bad migration or a missed
WHERE tenant_id = ?touches every tenant at once. If your team doesn't yet have migration review discipline and RLS-policy tests in CI, that operational risk is worth weighing against the cost savings.
6. Frequently Asked Questions
Let's address some common architectural questions when building multi-tenant SaaS platforms.
How does Row-Level Security (RLS) impact query performance in Supabase?
Row-Level Security appends policy logic directly to every SQL statement, so query complexity depends entirely on policy efficiency. Keep any column referenced inside an RLS policy — especially tenant_id — indexed, and avoid subqueries or joins inside the policy itself. Use a STABLE helper function reading from JWT claims instead.
What is the difference between single-database RLS and schema-per-tenant?
Single-database RLS stores all tenant data in shared tables, filtered by a tenant_id column and enforced by database policies. It's cost-effective, straightforward to scale, and keeps migrations simple. Schema-per-tenant creates a separate PostgreSQL schema per customer, offering stronger physical isolation at the cost of migration complexity that compounds as your tenant count grows.
How should connection pooling be configured for B2B SaaS workloads?
B2B workloads are bursty, especially during business hours, which can exhaust database connection limits. For serverless infrastructure like Next.js API routes or Server Components, connect through Supabase's Supavisor pooler in transaction mode (port 6543) so connections return to the pool as soon as each query completes, rather than staying open for the life of the function.
Do I need middleware.ts or proxy.ts for tenant routing in Next.js?
If you're on Next.js 16 or later, use proxy.ts — middleware.ts is deprecated and Vercel provides a codemod to migrate automatically. If you're on Next.js 15 or earlier, middleware.ts still works exactly as documented; there's no urgency to upgrade solely for this rename. Either way, keep authentication decisions out of this layer and resolve them in a Server Component or Route Handler.
How many tenants can a single Postgres database with RLS realistically support?
With a properly indexed tenant_id and JWT-based policies in place, a single well-tuned Postgres primary can comfortably serve thousands of tenants and tens of millions of rows before the isolation model itself becomes the bottleneck. In practice, teams hit connection limits or noisy-neighbor query patterns long before they hit a row-count ceiling — which is exactly why pooling (Section 4) and a clear "when not to" list (Section 5) matter more than raw scale numbers.
7. Strategic Takeaways
Building a B2B SaaS with Next.js and Supabase pairs fast developer iteration with genuine enterprise-grade security — provided the details in each layer are handled deliberately rather than copied from a six-month-old tutorial.
- Write indexable, subquery-free RLS policies from the start — retrofitting an index after a customer complains about a slow dashboard is a much harder conversation than adding one up front.
- Build tenant routing on
proxy.tsif you're on Next.js 16+, and keep it a thin routing layer — authentication belongs downstream. - Provision custom domains through the Vercel Domains API, not manual DNS support tickets, if you want enterprise buyers to trust the platform.
- Put Supavisor in transaction mode in front of every serverless connection — this one setting prevents the most common "worked in dev, fell over in prod" incident on this stack.
- Know your exit ramp: the moment compliance or a single outsized tenant is driving the conversation, shared-schema RLS is no longer the answer — plan the schema-per-tenant or separate-database path before you need it, not during an incident.
If you're building this for a live product rather than a side project, the same decisions above — isolation model, routing, pooling, and where the exit ramps are — are exactly what our full-stack SaaS development process is built around. PosifyHub, our production multi-tenant POS and inventory SaaS, runs on this same pattern. And if you're also evaluating open-source alternatives to a managed Postgres provider for the database layer itself, our high-availability database blueprint for 25M+ records covers that trade-off in depth.