Supabase Multi-Tenant Architecture: Best Practices 2026
Developer Guide

Supabase Multi-Tenant Architecture: Best Practices 2026

One codebase, isolated tenants: RLS policies, subdomain routing, and billing for a multi-tenant SaaS on Next.js + Supabase — with schema and code.

Updated
38 min read
Supabase Multi-Tenant Architecture: Best Practices 2026

Photo by FlyD on Unsplash

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:

sql
-- 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. The policies below already bake in the three optimizations Supabase's RLS guide documents — each detailed in the performance section that follows:

sql
-- 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 TO authenticated USING (
    id IN (
      SELECT tenant_id FROM tenant_users
      WHERE user_id = (SELECT auth.uid())
    )
  );
 
-- Projects: Scoped to the tenants the user belongs to
CREATE POLICY "Users can view tenant projects" ON projects
  FOR SELECT TO authenticated USING (
    tenant_id IN (
      SELECT tenant_id FROM tenant_users
      WHERE user_id = (SELECT auth.uid())
    )
  );
 
CREATE POLICY "Users can create projects in their tenant" ON projects
  FOR INSERT TO authenticated WITH CHECK (
    tenant_id IN (
      SELECT tenant_id FROM tenant_users
      WHERE user_id = (SELECT auth.uid())
    )
  );

3. Why These Policies Are Written This Way (RLS Performance)#

RLS runs on every row a query touches, so a slow policy multiplies across your whole table. Supabase publishes benchmarks for each of these patterns in the RLS guide's performance section:

(SELECT auth.uid()) instead of bare auth.uid(). Wrapping the function in a sub-select "causes an initPlan to be run by the Postgres optimizer, which allows it to 'cache' the results per-statement, rather than calling the function on each row." Supabase's benchmark for this change alone: ~95% improvement (179ms → 9ms on their test table).

TO authenticated on every policy. Without a TO clause the policy body executes even for anon requests. With it, "the execution stops at the to authenticated step" — Supabase measured a 99%+ improvement for anon traffic hitting authenticated-only tables. Since every request through PostgREST carries a role, there is no reason to omit this.

Filter direction matters. Notice the policies select from tenant_users filtered by the user, then match tenant_id IN (...) — rather than joining tenant_users against each projects row. This is Supabase's documented rewrite ("instead of joining... organize your policy to fetch the filter criteria into a set"), benchmarked at 99.78% faster (9,000ms → 20ms) on 100k rows. One caveat from the same doc: IN lists beyond ~1,000 entries degrade — a user in thousands of tenants needs a different approach.

Index the policy columns. Every column an RLS policy filters on needs a b-tree index; Supabase measured >99.9% improvement from indexing the filter column alone:

sql
CREATE INDEX idx_tenant_users_user_id ON tenant_users USING btree (user_id);
CREATE INDEX idx_projects_tenant_id ON projects USING btree (tenant_id);

When membership lookups get hot: JWT claims instead of subqueries. For read-heavy tables you can eliminate the tenant_users subquery entirely by putting the tenant ID into the JWT via app_metadata and reading it with auth.jwt(). Two safety rules from the docs are non-negotiable here: use raw_app_meta_data (which "cannot be updated by the user, so it's a good place to store authorization data"), never raw_user_meta_data (which the user can edit themselves — a self-service tenant-hopping exploit); and remember a JWT "is not always fresh" — a revoked membership stays in the token until the next refresh, so keep the database check for destructive operations.

Security definer functions are the remaining escape hatch: a SECURITY DEFINER function can read tenant_users without triggering its own RLS, avoiding recursive-policy problems. Supabase's warning applies: never create them in an exposed schema (like public when it's in your API's exposed schemas) — they'd be callable by anyone through the API.

Finally, remember what RLS does not cover: the service role key bypasses RLS entirely. That's what makes it right for webhooks and admin jobs and catastrophic in the browser — the failure modes are covered in what the service_role key actually does.

4. Tenant Context Management#

Create a tenant context system for the frontend:

typescript
// 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
}

5. Middleware for Tenant Resolution#

Implement tenant resolution from subdomain or path. Two details here come straight from Supabase's Next.js server-side auth guide: the cookie adapter uses the current getAll/setAll contract (the per-cookie get/set/remove shape you'll see in older tutorials is the previous API), and the user check uses auth.getUser() — Supabase's warning is explicit that you should never trust getSession() inside server code such as middleware, because it "isn't guaranteed to revalidate the Auth token."

typescript
// 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: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          response = NextResponse.next({
            request: { headers: request.headers },
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            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)$).*)',
  ],
}

6. Server Actions with Tenant Context#

Create tenant-aware server actions:

typescript
// 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
}

7. Usage Tracking and Billing Integration#

Implement usage tracking for plan limits:

sql
-- 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}');
typescript
// 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
}
sql
-- 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 — Supabase's own benchmarks show the difference between a naive policy and an optimized one is measured in orders of magnitude, not percent. Solution: Apply the four documented patterns from step 3 above: wrap auth.uid() in a SELECT, add TO authenticated, avoid joins in the policy body, and index every column a policy filters on. For hot paths, move tenant membership into app_metadata JWT claims.

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

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.