Multi-Tenant SaaS Architecture with Next.js and Supabase
Complete guide to building multi-tenant SaaS architecture with Next.js and Supabase. Learn tenant isolation, RLS policies, subdomain routing, and billing integration patterns.
Multi-Tenant SaaS Architecture with Next.js and Supabase#
Building a multi-tenant SaaS architecture with Next.js and Supabase requires careful planning for data isolation, security, and scalability. This comprehensive guide covers everything from database design to production deployment.
TL;DR#
Learn to build scalable multi-tenant SaaS applications where multiple customers share infrastructure while maintaining complete data isolation. You'll implement tenant-aware authentication, RLS policies, billing integration, and usage tracking using Next.js 15 and Supabase.
Prerequisites#
- Next.js 14+ experience with App Router
- Supabase fundamentals (auth, database, RLS)
- PostgreSQL knowledge (joins, indexes, triggers)
- Understanding of SaaS business models
Problem Statement#
Building separate application instances for each customer is expensive and hard to maintain. Multi-tenancy solves this by sharing infrastructure while ensuring complete data isolation, but introduces complexity around tenant identification, data security, and feature management.
What is Multi-Tenancy in SaaS Applications?#
Multi-tenancy allows multiple customers (tenants) to share the same application instance while keeping their data completely isolated. This architecture reduces infrastructure costs, simplifies maintenance, and enables faster feature delivery compared to single-tenant deployments.
How Do You Implement Tenant Isolation in Supabase?#
Supabase provides Row Level Security (RLS) policies that enforce tenant isolation at the database level. This ensures users can only access data they are authorized to see, even if they bypass your application code.
What Are the Best Practices for Multi-Tenant Database Design?#
Use shared database with tenant_id columns and RLS policies for most SaaS applications. This approach is more cost-effective and easier to maintain than separate databases per tenant, which should only be used for enterprise customers with strict compliance requirements.
Step-by-Step Walkthrough#
1. Database Schema Design#
First, establish the tenant model and data isolation strategy:
-- Core tenant table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan_id TEXT NOT NULL DEFAULT 'starter',
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- User-tenant relationship (users can belong to multiple tenants)
CREATE TABLE tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, user_id)
);
-- Example tenant-scoped table
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
2. Row Level Security Policies#
Implement tenant isolation at the database level:
-- Enable RLS on all tenant-scoped tables
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Tenants: Users can only see tenants they belong to
CREATE POLICY "Users can view their tenants" ON tenants
FOR SELECT USING (
id IN (
SELECT tenant_id FROM tenant_users
WHERE user_id = auth.uid()
)
);
-- Projects: Scoped to user's current tenant
CREATE POLICY "Users can view tenant projects" ON projects
FOR SELECT USING (
tenant_id IN (
SELECT tenant_id FROM tenant_users
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can create projects in their tenant" ON projects
FOR INSERT WITH CHECK (
tenant_id IN (
SELECT tenant_id FROM tenant_users
WHERE user_id = auth.uid()
)
);
3. Tenant Context Management#
Create a tenant context system for the frontend:
// lib/tenant-context.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Tenant {
id: string
name: string
slug: string
plan_id: string
settings: Record<string, any>
}
interface TenantContextType {
tenant: Tenant | null
setTenant: (tenant: Tenant) => void
loading: boolean
}
const TenantContext = createContext<TenantContextType | undefined>(undefined)
export function TenantProvider({
children,
initialTenant
}: {
children: React.ReactNode
initialTenant?: Tenant
}) {
const [tenant, setTenant] = useState<Tenant | null>(initialTenant || null)
const [loading, setLoading] = useState(!initialTenant)
const supabase = createClient()
useEffect(() => {
if (!initialTenant) {
loadUserTenant()
}
}, [initialTenant])
async function loadUserTenant() {
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data: tenantUsers } = await supabase
.from('tenant_users')
.select('tenant:tenants(*)')
.eq('user_id', user.id)
.limit(1)
.single()
if (tenantUsers?.tenant) {
setTenant(tenantUsers.tenant as Tenant)
}
} catch (error) {
console.error('Failed to load tenant:', error)
} finally {
setLoading(false)
}
}
return (
<TenantContext.Provider value={{ tenant, setTenant, loading }}>
{children}
</TenantContext.Provider>
)
}
export function useTenant() {
const context = useContext(TenantContext)
if (context === undefined) {
throw new Error('useTenant must be used within a TenantProvider')
}
return context
}
4. Middleware for Tenant Resolution#
Implement tenant resolution from subdomain or path:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: any) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: any) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
// Extract tenant from subdomain (e.g., acme.yourapp.com)
const hostname = request.headers.get('host') || ''
const subdomain = hostname.split('.')[0]
// Skip tenant resolution for main domain
if (subdomain === 'www' || subdomain === 'yourapp') {
return response
}
// Verify tenant exists and user has access
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data: tenant } = await supabase
.from('tenants')
.select('id, name, slug, plan_id')
.eq('slug', subdomain)
.single()
if (tenant) {
// Verify user belongs to this tenant
const { data: membership } = await supabase
.from('tenant_users')
.select('role')
.eq('tenant_id', tenant.id)
.eq('user_id', user.id)
.single()
if (membership) {
// Add tenant info to headers for server components
response.headers.set('x-tenant-id', tenant.id)
response.headers.set('x-tenant-slug', tenant.slug)
return response
}
}
}
// Redirect to main domain if tenant not found or no access
return NextResponse.redirect(new URL('/', request.url))
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
5. Server Actions with Tenant Context#
Create tenant-aware server actions:
// lib/actions/projects.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
export async function createProject(formData: FormData) {
const supabase = createClient()
const headersList = headers()
const tenantId = headersList.get('x-tenant-id')
if (!tenantId) {
throw new Error('Tenant context required')
}
const name = formData.get('name') as string
const description = formData.get('description') as string
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Authentication required')
const { data, error } = await supabase
.from('projects')
.insert({
tenant_id: tenantId,
name,
description,
created_by: user.id
})
.select()
.single()
if (error) throw error
revalidatePath('/projects')
return data
}
export async function getProjects() {
const supabase = createClient()
const headersList = headers()
const tenantId = headersList.get('x-tenant-id')
if (!tenantId) {
throw new Error('Tenant context required')
}
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
if (error) throw error
return data
}
6. Usage Tracking and Billing Integration#
Implement usage tracking for plan limits:
-- Usage tracking table
CREATE TABLE tenant_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
metric TEXT NOT NULL, -- 'projects', 'storage_mb', 'api_calls'
value INTEGER NOT NULL DEFAULT 0,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, metric, period_start)
);
-- Plan limits configuration
CREATE TABLE plan_limits (
plan_id TEXT PRIMARY KEY,
limits JSONB NOT NULL -- {"projects": 10, "storage_mb": 1000, "api_calls": 10000}
);
INSERT INTO plan_limits VALUES
('starter', '{"projects": 5, "storage_mb": 500, "api_calls": 1000}'),
('pro', '{"projects": 50, "storage_mb": 10000, "api_calls": 50000}'),
('enterprise', '{"projects": -1, "storage_mb": -1, "api_calls": -1}');
// lib/usage-tracking.ts
import { createClient } from '@/lib/supabase/server'
export async function trackUsage(
tenantId: string,
metric: string,
increment: number = 1
) {
const supabase = createClient()
const now = new Date()
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1)
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const { error } = await supabase.rpc('increment_usage', {
p_tenant_id: tenantId,
p_metric: metric,
p_increment: increment,
p_period_start: periodStart.toISOString().split('T')[0],
p_period_end: periodEnd.toISOString().split('T')[0]
})
if (error) throw error
}
export async function checkUsageLimit(
tenantId: string,
metric: string
): Promise<{ allowed: boolean; current: number; limit: number }> {
const supabase = createClient()
const { data, error } = await supabase.rpc('check_usage_limit', {
p_tenant_id: tenantId,
p_metric: metric
})
if (error) throw error
return data
}
-- Database functions for usage tracking
CREATE OR REPLACE FUNCTION increment_usage(
p_tenant_id UUID,
p_metric TEXT,
p_increment INTEGER,
p_period_start DATE,
p_period_end DATE
) RETURNS VOID AS $$
BEGIN
INSERT INTO tenant_usage (tenant_id, metric, value, period_start, period_end)
VALUES (p_tenant_id, p_metric, p_increment, p_period_start, p_period_end)
ON CONFLICT (tenant_id, metric, period_start)
DO UPDATE SET value = tenant_usage.value + p_increment;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION check_usage_limit(
p_tenant_id UUID,
p_metric TEXT
) RETURNS JSON AS $$
DECLARE
current_usage INTEGER := 0;
usage_limit INTEGER := -1;
tenant_plan TEXT;
BEGIN
-- Get tenant's current plan
SELECT plan_id INTO tenant_plan
FROM tenants WHERE id = p_tenant_id;
-- Get current month usage
SELECT COALESCE(value, 0) INTO current_usage
FROM tenant_usage
WHERE tenant_id = p_tenant_id
AND metric = p_metric
AND period_start = DATE_TRUNC('month', CURRENT_DATE);
-- Get plan limit
SELECT (limits->p_metric)::INTEGER INTO usage_limit
FROM plan_limits WHERE plan_id = tenant_plan;
RETURN json_build_object(
'allowed', usage_limit = -1 OR current_usage < usage_limit,
'current', current_usage,
'limit', usage_limit
);
END;
$$ LANGUAGE plpgsql;
Common Pitfalls#
1. Forgetting Tenant Context in Queries#
Problem: Queries without tenant filtering can leak data between tenants. Solution: Always include tenant_id in WHERE clauses. Use RLS as a safety net, not the primary filter.
2. Hardcoding Tenant IDs#
Problem: Hardcoded tenant IDs in client code can be manipulated. Solution: Always resolve tenant context server-side through middleware or headers.
3. Inefficient RLS Policies#
Problem: Complex RLS policies can slow down queries significantly. Solution: Keep policies simple. Use indexes on tenant_id columns. Consider caching tenant membership.
Production Considerations#
Performance Optimization#
- Add indexes on all tenant_id columns
- Use connection pooling to handle multiple tenants
- Consider read replicas for analytics queries
- Implement query result caching with tenant-aware cache keys
Security Hardening#
- Audit RLS policies regularly with automated tests
- Implement tenant-aware rate limiting
- Log all cross-tenant access attempts
- Use separate service accounts for different operations
Scalability Planning#
- Monitor tenant growth and resource usage
- Plan for tenant data archival and cleanup
- Consider sharding strategies for very large tenants
- Implement graceful degradation for overloaded tenants
Further Reading#
- Advanced Authentication Patterns - Complex multi-level tenant hierarchies and enterprise auth
- Security Best Practices - Comprehensive security hardening for multi-tenant systems
- Multi-Region Deployment - Geographic tenant distribution strategies
- Compliance Implementation - GDPR, SOC2 for multi-tenant systems
- Performance Monitoring - Tenant-specific observability and alerting
Frequently Asked Questions
Related Guides
Complete Guide to Building SaaS with Next.js and Supabase
Master full-stack SaaS development with Next.js 15 and Supabase. From database design to deployment, learn everything you need to build production-ready...
Security Best Practices for Next.js and Supabase Applications
Comprehensive security guide for Next.js and Supabase applications. Learn RLS policies, secret management, API security, authentication hardening, and production security checklist.
Next.js Webhook Handling and Event-Driven Architecture
Learn webhook handling and event-driven architecture with Next.js and Supabase. Complete tutorial covering webhook security, retry mechanisms, and distributed system patterns.