Building a Multi-Tenant SaaS Platform with Next.js and Supabase
Building a multi-tenant SaaS platform is one of the most common—and most challenging—requests we get at Axiosware. The difference between a prototype and a production-ready platform often comes down to how you handle tenant isolation, data security, and scalability from day one.
Key Takeaways
- Row-level security (RLS) is non-negotiable—it's your primary defense against data leaks
- Choose your isolation model wisely: shared database with tenant_id vs. separate schemas vs. isolated databases
- Next.js middleware handles tenant routing more elegantly than traditional frameworks
- Stripe integration patterns for multi-tenant billing and subscription management
- Real-world metrics from our 24+ shipped SaaS platforms
The Multi-Tenant Architecture Decision
When clients ask us to build a multi-tenant SaaS, the first question isn't about frameworks—it's about data isolation. There are three primary models:
1. Shared Database, Shared Schema (Most Common)
Every tenant's data lives in the same tables, distinguished by a tenant_id column. This is what 85% of modern SaaS platforms use, including most Supabase-based applications.
The Stack
Frontend: Next.js 14 with App Router
Backend: Supabase (PostgreSQL with Row-Level Security)
Auth: Supabase Auth or Clerk with tenant context
Payments: Stripe with multi-tenant webhook handling
Deployment: Vercel + Supabase Cloud
2. Shared Database, Separate Schemas
Each tenant gets their own schema within the same database. Better isolation but more complex migrations and queries.
3. Completely Isolated Databases
Each tenant gets their own database instance. Maximum security but highest operational overhead. Only recommended for enterprise/healthcare compliance requirements.
For most startups and growth-stage companies, option 1 with proper RLS gives you the best balance of development speed, operational simplicity, and security.
Setting Up Row-Level Security
Row-Level Security (RLS) is Supabase's secret weapon for multi-tenant applications. Without it, you're relying on application-level checks that can be bypassed. With RLS, the database itself enforces tenant isolation.
Here's a production-ready RLS policy setup:
-- Enable RLS on all tenant-scoped tables
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Create a function to get the current tenant ID from JWT claims
CREATE OR REPLACE FUNCTION get_current_tenant_id()
RETURNS UUID AS $
SELECT current_setting('app.current_tenant_id', true)::UUID;
$ LANGUAGE SQL SECURITY DEFINER;
-- Create a function to set tenant context after auth
CREATE OR REPLACE FUNCTION set_tenant_context(tenant_id UUID)
RETURNS VOID AS $
BEGIN
SET app.current_tenant_id = tenant_id::text;
END;
$ LANGUAGE plpgsql SECURITY DEFINER;
-- RLS Policy: Users can only access their tenant's data
CREATE POLICY tenant_isolation_policy ON customers
USING (tenant_id = get_current_tenant_id());
CREATE POLICY tenant_isolation_orders ON orders
USING (tenant_id = get_current_tenant_id());
CREATE POLICY tenant_isolation_products ON products
USING (tenant_id = get_current_tenant_id());
-- Ensure new tenants are created with proper context
CREATE OR REPLACE FUNCTION create_new_tenant(
tenant_name TEXT,
owner_email TEXT
) RETURNS UUID AS $
DECLARE
new_tenant_id UUID;
BEGIN
INSERT INTO tenants (name, created_at)
VALUES (tenant_name, NOW())
RETURNING id INTO new_tenant_id;
PERFORM set_tenant_context(new_tenant_id);
RETURN new_tenant_id;
END;
$ LANGUAGE plpgsql SECURITY DEFINER;
The critical piece here is setting the tenant context in the JWT claims after authentication. Your auth flow should:
- User authenticates via Supabase Auth or Clerk
- Backend looks up user's tenant_id from the
user_tenantsjoin table - JWT is issued with
app.current_tenant_idclaim - Supabase automatically applies RLS policies based on this claim
Next.js Middleware for Tenant Routing
Next.js middleware gives you elegant tenant routing without complex URL parsing. Here's a production pattern:
// middleware.ts
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const { searchParams } = new URL(request.url);
const tenantSubdomain = searchParams.get('tenant');
if (!tenantSubdomain) {
// Redirect to onboarding if no tenant selected
return NextResponse.redirect(new URL('/onboarding', request.url));
}
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// Fetch tenant from database
const { data: tenant, error } = await supabase
.from('tenants')
.select('id, name, status')
.eq('subdomain', tenantSubdomain)
.eq('status', 'active')
.single();
if (error || !tenant) {
return NextResponse.redirect(new URL('/not-found', request.url));
}
// Set tenant context in headers for downstream use
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-name', tenant.name);
return response;
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};
This middleware runs on every request, validates the tenant exists, and sets headers that your API routes can use for additional context. The beauty is that RLS still enforces the actual data access—headers are just for display and logging.
Multi-Tenant Stripe Integration
Billing is where most multi-tenant SaaS platforms stumble. You need to handle:
- Per-tenant subscriptions with different tiers
- Webhook routing to the correct tenant
- Proration and upgrades/downgrades
- Invoice generation and email delivery
Here's how we structure Stripe metadata for multi-tenant billing:
// lib/stripe.ts
import Stripe from 'stripe';
export async function createTenantSubscription(
tenantId: string,
customerId: string,
priceId: string,
trialDays?: number
) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
metadata: {
tenant_id: tenantId,
subscription_type: 'saas_platform',
},
subscription_data: {
metadata: {
tenant_id: tenantId,
},
trial_period_days: trialDays || 14,
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing/cancel`,
});
return session;
}
// Webhook handler for subscription events
export async function handleStripeWebhook(event: Stripe.Event) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const sig = event.raw_event.headers['stripe-signature'];
const session = await stripe.webhooks.constructAsyncEvent(
event.raw_event,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (session.type) {
case 'customer.subscription.updated': {
const subscription = session.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenant_id;
// Update tenant's subscription status in your database
await supabase
.from('tenant_subscriptions')
.update({
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000),
plan: subscription.plan.id,
})
.eq('tenant_id', tenantId);
break;
}
case 'customer.subscription.deleted': {
const subscription = session.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenant_id;
// Grace period before deactivating tenant
await supabase
.from('tenants')
.update({ status: 'grace_period' })
.eq('id', tenantId);
break;
}
}
return { received: true };
}
The key insight: store the Stripe customer ID and subscription ID in your tenant metadata, not just in Stripe. This lets you look up billing state from either side of the system.
Case Study: Isla Hotel Reservation Platform
When we built the Isla Hotel reservation platform, we faced a similar multi-tenant challenge. Each hotel property needed its own inventory, pricing rules, and booking flow, but all sharing the same codebase.
The Challenge
6 hotel chains, each with 3-12 properties. Need to handle 10x traffic spikes during peak booking seasons while keeping data completely isolated between properties.
The Solution
- Next.js 13 with App Router for server-side rendering
- Supabase with RLS for property-level data isolation
- Redis caching for booking availability queries
- Stripe Connect for multi-property payment splitting
Results
Launched in 6 weeks. Achieved 68% reduction in front-desk calls for property managers. Handled 3x traffic during peak season with zero downtime.
Common Pitfalls to Avoid
1. Relying Only on Application-Level Checks
Never trust client-side tenant validation alone. Always enforce at the database level with RLS. We've seen too many "just a quick prototype" apps leak data because someone forgot to add a WHERE tenant_id = ? clause in one query.
2. Ignoring Migration Strategy
When you add a new column to a tenant-scoped table, every tenant's data is affected. Use pg_partman for table partitioning if you expect >1M rows per tenant. For smaller scales, simple indexes on tenant_id are sufficient.
3. Forgetting Global Tables
Not everything is tenant-scoped. User accounts, admin panels, and billing systems often need cross-tenant visibility. Clearly document which tables are global vs. tenant-scoped from day one.
4. Over-Engineering Early
Start with shared schema. If you hit performance issues at 100+ tenants, then consider partitioning. Most SaaS platforms don't need separate schemas until they're already successful.
Performance Considerations
At Axiosware, we've learned that multi-tenant performance comes down to three things:
1. Index Strategy
Every tenant-scoped table needs an index on tenant_id. For frequently queried combinations, use composite indexes like (tenant_id, created_at DESC).
2. Connection Pooling
Supabase handles this automatically, but if you're running your own PostgreSQL, use PgBouncer. A single Next.js instance can open 1000+ connections during peak traffic—pooling keeps costs manageable.
3. Caching Layers
Use Redis for frequently accessed tenant data like configuration, pricing rules, and user preferences. Cache invalidation becomes critical when multiple tenants share the same codebase.
When to Consider Alternatives
Multi-tenant SaaS isn't always the right answer. Here's when we recommend simpler approaches:
- Under 10 customers: Consider a single-tenant setup with separate databases. Simpler operations, easier compliance.
- Highly regulated industries: Healthcare (HIPAA), finance (SOC2) may require isolated databases from day one.
- Custom per-client deployments: If each client needs custom features, a template-based approach with separate repos might be better.
- Very small teams: No-code tools like Bubble or Softr can get you to $10K MRR faster than building custom infrastructure.
At Axiosware, we're honest about tradeoffs. Sometimes the best advice is "don't build a multi-tenant SaaS yet." But when you do need it, the patterns above have served us well across 24+ shipped products.
Ready to Build Your Multi-Tenant SaaS?
Whether you're a pre-seed founder looking to validate a SaaS idea or an established business needing a custom multi-tenant platform, we can help. Our Growth Engine package is specifically designed for multi-platform SaaS builds with Stripe integration, admin dashboards, and AI features.
We've helped companies like Lefty's Cheesesteaks scale from zero to 4.2x increase in online orders, and Michigan Sprinter Center generate $185K in their first quarter with a custom e-commerce platform.
Ready to Build?
Let's discuss your multi-tenant SaaS vision. We'll help you choose the right architecture, pricing model, and tech stack for your specific needs.
Start a ProjectWant a deeper dive? Download our free Multi-Tenant SaaS Architecture Checklist that covers everything from RLS policies to Stripe webhook handling.
Tags
Want More Engineering Insights?
Get startup architecture patterns, AI development techniques, and product launch strategies delivered to your inbox.
Join the Axiosware Newsletter
Weekly insights for founders and technical leaders
We respect your privacy. Unsubscribe at any time.
