Multi-Tenant SaaS Architecture with Next.js and Supabase
Developer Guide

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.

2026-03-12
35 min read
Multi-Tenant SaaS Architecture with Next.js and Supabase

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#

  1. Advanced Authentication Patterns - Complex multi-level tenant hierarchies and enterprise auth
  2. Security Best Practices - Comprehensive security hardening for multi-tenant systems
  3. Multi-Region Deployment - Geographic tenant distribution strategies
  4. Compliance Implementation - GDPR, SOC2 for multi-tenant systems
  5. Performance Monitoring - Tenant-specific observability and alerting

Frequently Asked Questions

|

Have more questions? Contact us