Multi-Tenant SaaS Architecture in Next.js: Production Patterns
When you're building a SaaS product that serves multiple customers from a single codebase, multi-tenancy isn't a feature — it's the foundation. Get it wrong and you're looking at data leaks, performance bottlenecks, and a deployment nightmare. Get it right and you can scale from 10 to 10,000 customers without rewriting your stack.
We've shipped 16 industry-specific CRMs — HotelDesk, LegalEase, PharmaCare, CargoTrack — all running on Next.js with shared infrastructure. Here's what actually works in production, and what breaks at scale.
The Three Multi-Tenancy Models
Before you write a single line of code, pick your isolation strategy. There are three main approaches:
1. Database per tenant
Each customer gets their own Postgres instance or schema. Complete isolation, easy to backup individual tenants, simple compliance story. The downside? Operational complexity explodes. Managing 200 databases means 200 migration runs, 200 connection pools, 200 points of failure. Works well if you have 10-50 enterprise clients who demand contractual data isolation. Doesn't scale for SMB SaaS.
2. Schema per tenant
Single database, separate schema per customer. Middle ground between isolation and manageability. Postgres handles this reasonably well, but you're still running N migration scripts and dealing with connection pool limits. We've used this for clients with strict regulatory requirements — healthcare, legal — where logical separation isn't enough.
3. Shared database with tenant column
Every table has a tenant_id column. One database, one schema, tenant isolation enforced at the query level. This is what scales. It's what Slack uses, what we use for most of our CRM products, and what you should default to unless you have a specific reason not to.
The tradeoff: You must get row-level security right. One missed WHERE clause and Customer A sees Customer B's data. This isn't theoretical — it happens.
Row-Level Security: The Non-Negotiable Layer
If you're going with shared tables, Postgres Row-Level Security (RLS) is your insurance policy. Here's the pattern we use:
CREATE POLICY tenant_isolation ON bookings
USING (tenant_id = current_setting('app.current_tenant')::uuid);
In your Next.js API routes or Server Actions, set the tenant context before any query:
await sql`SET app.current_tenant = ${tenantId}`;
const bookings = await sql`SELECT * FROM bookings`;
Even if a developer forgets the WHERE clause, Postgres enforces the policy. No tenant_id in the query? No problem — RLS handles it. This saved us during a code review when a junior dev wrote a global admin query that would've leaked data across tenants.
Caveats:
- RLS has a small performance overhead. On tables with millions of rows, we've seen 5-10% slower queries. Acceptable tradeoff for security.
- You need connection pooling that preserves session state (PgBouncer in transaction mode won't work — use session mode or Supabase's Supavisor).
- Migrations and admin queries need to bypass RLS. Use a separate privileged connection pool.
Tenant Detection in Next.js
How does your app know which tenant the incoming request belongs to? Three common patterns:
Subdomain routingcustomer1.yourapp.com vs customer2.yourapp.com. Clean UX, each customer gets a branded URL. Wildcard SSL required (Let's Encrypt makes this free). In Next.js middleware:
export function middleware(req: NextRequest) {
const hostname = req.headers.get('host');
const subdomain = hostname?.split('.')[0];
const tenant = await getTenantBySubdomain(subdomain);
// Attach to request context
}
Works great until a customer wants a custom domain (bookings.theirhotel.com). Then you need CNAME verification and dynamic SSL provisioning. We built this for HotelDesk — it's doable but adds complexity.
Path-based routingyourapp.com/customer1/dashboard. Simpler infrastructure, no DNS complexity. Downside: less professional for customer-facing apps. We use this for internal tools and back-office systems.
JWT claims
Mobile apps and SPAs can pass tenant_id in the auth token. Your API extracts it from the JWT payload. Fast, stateless, but requires rock-solid token validation. One misconfigured public key and you're toast.
Shared vs Isolated Infrastructure
Beyond the database, you'll make multi-tenancy decisions across your stack:
- File uploads: S3 with tenant prefixes (
s3://bucket/tenant-123/invoices/) or separate buckets? - Background jobs: Shared queue with tenant context, or per-tenant queues?
- Redis cache: Single instance with keyed prefixes, or separate instances?
- Feature flags: Tenant-specific overrides in your flag system?
We default to shared infrastructure with logical separation. It's cheaper, simpler to monitor, and scales better. The exception: if a tenant pays for dedicated resources (enterprise tier), we provision isolated compute and cache layers but still use the shared database with RLS.
Feature Gating and Tenant Config
Every tenant needs different features enabled. LegalEase clients get case management; PharmaCare clients get prescription tracking. Store this in a tenant_config JSONB column:
type TenantConfig = {
features: string[];
limits: { max_users: number; max_storage_gb: number };
branding: { logo_url: string; primary_color: string };
integrations: { stripe_account_id?: string };
};
Check features in Server Components:
const tenant = await getTenant(tenantId);
if (!tenant.config.features.includes('advanced_reports')) {
return <UpgradeCTA />;
}
This powers our AI agent specialties too — tenants can enable AI-powered lead scoring or customer support bots based on their plan.
Migration Strategy
Shared-table migrations are straightforward: run once, affects all tenants. But what if you need to roll out a breaking change to 20% of customers first?
We use a tenant_migrations table to track per-tenant schema versions:
CREATE TABLE tenant_migrations (
tenant_id UUID,
migration_name TEXT,
applied_at TIMESTAMP
);
Optional columns (like new_field) start NULL. The app checks the tenant's migration status and adapts queries. Once all tenants migrate, we make it required and drop the conditional logic. Messy during the transition, but it's saved us from downtime during major refactors.
The Numbers That Matter
From our 16 CRM deployments:
- Shared DB + RLS handles 500+ tenants on a single Postgres instance (16 vCPU, 64GB RAM).
- 95th percentile query latency under 50ms with proper indexing on
(tenant_id, created_at)composite indexes. - Zero cross-tenant data leaks in production since implementing RLS (knock on wood).
- Deployment time: 3 minutes for all tenants vs 45+ minutes when we tried schema-per-tenant.
The hardest part isn't the architecture — it's discipline. Every new feature, every query, every background job must respect tenant boundaries. Code reviews focus on this. We have a linter rule that flags raw SQL without tenant filters. Automate the checks you can, because humans will forget.
When to Go Multi-Tenant
Not every SaaS needs this from day one. If you're validating product-market fit with 5 beta customers, a simple approach (even separate deploys) is fine. Premature architecture is expensive.
Switch to multi-tenancy when:
- You're onboarding tenants faster than you can provision infrastructure.
- Ops overhead (deployments, monitoring, backups) becomes the bottleneck.
- You need to ship features once and have them available to all customers instantly.
For us, that inflection point was around tenant 30. Below that, the juice wasn't worth the squeeze. Above that, single-tenant deploys would've killed us.
The Bottom Line
Multi-tenant SaaS in Next.js is production-ready, but it demands upfront architectural decisions you can't easily reverse. Shared database + RLS + subdomain routing is the pattern we'd choose again. It scales, it's secure (when done right), and it keeps your development services team focused on features instead of infrastructure.
The rest is testing, monitoring, and paranoia. Because in multi-tenant systems, a bug doesn't affect one customer — it affects all of them.