supabase-reference-architecture
Implement enterprise Supabase reference architectures — monorepo layout, multi-tenant RLS, microservices with cross-project access, framework integration, edge functions, caching, queue patterns, and audit logging. Use when designing a new Supabase project from scratch, reviewing project structure for production readiness, planning multi-tenant isolation, or establishing team architecture standards. Trigger with phrases like "supabase architecture", "supabase project structure", "supabase monorepo", "supabase multi-tenant", "supabase reference design", "how to organize supabase at scale".
Allowed Tools
Provided by Plugin
supabase-pack
Claude Code skill pack for Supabase (30 skills)
Installation
This skill is included in the supabase-pack plugin:
/plugin install supabase-pack@claude-code-plugins-plus
Click to copy
Instructions
Supabase Reference Architecture
Overview
Production Supabase applications need more than a flat lib/supabase.ts file. This skill covers five enterprise architecture patterns: monorepo with shared types, multi-tenant RLS isolation, microservices with separate Supabase projects, framework integration (Next.js / SvelteKit), and operational patterns (edge functions, caching, queues, audit trails). Each pattern stands alone — pick the ones that match your scale.
For the full monorepo directory layout and microservices cross-project access, see Project Structure. For edge functions, caching, queue, and audit trail patterns, see Operational Patterns.
Prerequisites
@supabase/supabase-jsv2+ installed (npm install @supabase/supabase-js)- Supabase CLI installed (
npm install --save-dev supabase) - A Supabase project at supabase.com/dashboard
- Familiarity with
supabase-install-auth(project URL, anon key, service role key) - PostgreSQL basics (RLS policies, triggers, functions)
Instructions
Step 1: Browser Client and Isolated Admin Client
Share configuration and generated types, not request-bound server sessions. The singleton below is for standalone browser apps without SSR; SSR-backed browser components use the cookie-aware factory in Step 3. Create each SSR user client inside its request. Never cache authenticated responses or share a server user client across requests.
// packages/supabase/src/client.ts — browser-only; never use as an SSR singleton
import { createClient, SupabaseClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
let client: SupabaseClient<Database> | null = null
export function getSupabaseClient(): SupabaseClient<Database> {
if (!client) {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_ANON_KEY
if (!url || !key) {
throw new Error('Missing SUPABASE_URL or SUPABASE_ANON_KEY environment variables')
}
client = createClient<Database>(url, key)
}
return client
}
// Reset for testing
export function resetClient(): void {
client = null
}
// packages/supabase/src/admin.ts — isolated privileged module for Next.js
import 'server-only'
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
export function getSupabaseAdmin() {
const url = process.env.SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !serviceKey) {
throw new Error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY — server-only')
}
return createClient<Database>(url, serviceKey, {
auth: { autoRefreshToken: false, persistSession: false }
})
}
The admin client bypasses RLS. Never attach user cookies or a user session to it; invoke it only after application-owned authorization for a specific privileged operation. Keep it out of shared client exports. Other server runtimes need an equivalent build/import boundary.
Step 2: Read-Only Multi-Tenant Membership Policies
This example authorizes reads through trusted membership rows, not user-editable metadata or a fabricated JWT claim. Only a separately authorized administrative process may provision tenants, memberships, or projects; no user write policies are supplied.
-- Migration: 20260101000000_create_tenants.sql
-- Tenants table
create table public.tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
plan text default 'free' check (plan in ('free', 'pro', 'enterprise')),
created_at timestamptz default now()
);
-- Tenant membership
create table public.tenant_members (
tenant_id uuid references public.tenants(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
primary key (tenant_id, user_id)
);
-- Example tenant-scoped table
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.tenants(id) on delete cascade,
name text not null,
created_by uuid references auth.users(id),
created_at timestamptz default now()
);
-- Enable RLS on every exposed table, including the authorization table.
alter table public.tenants enable row level security;
alter table public.tenant_members enable row level security;
alter table public.projects enable row level security;
-- Read-only application surface: remove existing direct role grants first.
revoke all privileges on public.tenants, public.tenant_members, public.projects
from public, anon, authenticated;
grant select on public.tenants, public.tenant_members, public.projects
to authenticated;
-- This policy reads only the current row; it does not query itself recursively.
create policy "Read own memberships" on public.tenant_members
for select to authenticated
using (user_id = (select auth.uid()));
create policy "Read member tenants" on public.tenants
for select to authenticated
using (exists (
select 1 from public.tenant_members as membership
where membership.tenant_id = tenants.id
and membership.user_id = (select auth.uid())
));
create policy "Read member projects" on public.projects
for select to authenticated
using (exists (
select 1 from public.tenant_members as membership
where membership.tenant_id = projects.org_id
and membership.user_id = (select auth.uid())
));
This is a new-table migration example. On existing tables, review other policies and inherited grants; a permissive policy can widen access. The table-local revocations above also remove grants inherited through PUBLIC, including configured defaults; they do not alter schema-wide defaults or privileges on other tables. Test as ordinary authenticated users, not the table owner or an RLS-bypassing admin. A query's tenant filter narrows results but never grants membership. Writes require a separate reviewed authorization design; do not add a blanket for all policy.
Step 3: Framework Integration (Next.js)
User-facing SSR uses a request-scoped @supabase/ssr client with a publishable key (or legacy anon key) and cookie adapters. A plain Cookie header on a privileged client is not a user authorization boundary.
// app/lib/supabase-server.ts — cookie-writable Route Handlers / Server Actions
import 'server-only'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@my-platform/supabase'
export async function getSupabaseServer() {
const cookieStore = await cookies()
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!key) throw new Error('Missing Supabase publishable or anon key')
return createServerClient<Database>(process.env.NEXT_PUBLIC_SUPABASE_URL!, key, {
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (updates) => {
for (const { name, value, options } of updates) {
cookieStore.set(name, value, options)
}
}
}
})
}
// app/api/projects/route.ts — read-only, user-authenticated request
import { getSupabaseServer } from '@/app/lib/supabase-server'
export async function GET() {
const supabase = await getSupabaseServer()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return Response.json({ error: 'Unauthorized' }, {
status: 401, headers: { 'Cache-Control': 'private, no-store' }
})
const { data, error } = await supabase.from('projects')
.select('id, name, created_at').order('created_at', { ascending: false }).limit(50)
if (error) return Response.json({ error: 'Project query failed' }, {
status: 500, headers: { 'Cache-Control': 'private, no-store' }
})
return Response.json(data, { headers: { 'Cache-Control': 'private, no-store' } })
}
Server Components cannot persist refreshed cookies. Configure the framework's Proxy/session-refresh route and its request/response cookie propagation before rendering; do not reuse the writable helper above in a read-only context or silently discard refresh writes. See the SSR client guide for the framework-specific adapter. Never cache a response carrying another user's session.
// app/lib/supabase-browser.ts — same cookie session as the SSR client
'use client'
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@my-platform/supabase'
let browserClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowser() {
if (!browserClient) {
browserClient = createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
(process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)!
)
}
return browserClient
}
For SvelteKit integration and additional framework patterns, see Examples.
Output
After applying these patterns you will have:
- Monorepo with shared Supabase client, typed database access, and centralized migrations
- Read-only tenant membership policies, with tenant filters used only for query scope
- Framework-specific integration for Next.js (server/client split) and SvelteKit (hooks)
- Edge Functions, caching layer, job queue, and audit trail (see Operational Patterns)
Error Handling
| Error | Cause | Solution |
|---|---|---|
Missing SUPABASE_URL or SUPABASE_ANON_KEY |
Environment variables not set | Check .env file and ensure variables are loaded |
new row violates row-level security policy |
RLS blocks the operation | Check verified user identity, role grants, and the trusted membership row |
| Empty tenant result | No readable membership or no matching rows | Check the requested tenant and trusted membership; never bypass RLS |
TypeError: Cannot read properties of null |
Client singleton not initialized | Ensure env vars are available before first getSupabaseClient() call |
cron.schedule: permission denied |
pg_cron extension not enabled |
Enable via dashboard: Database > Extensions > pg_cron |
For the full error reference including RLS debugging and cross-project troubleshooting, see Error Handling Reference.
Examples
Multi-Tenant Query Flow (TypeScript)
// Use the current request's authenticated, non-admin client.
import { getSupabaseServer } from './supabase-server'
export async function readTenantProjects(tenantId: string) {
const supabase = await getSupabaseServer()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('Authentication required')
const { data, error } = await supabase.from('projects')
.select('id, name, created_at')
.eq('org_id', tenantId) // Query scope only; membership RLS authorizes access.
.order('created_at', { ascending: false })
if (error) throw new Error('Project query failed')
return data
}
For the job queue consumer example and SvelteKit integration, see Examples.
Resources
- Supabase Architecture
- Row Level Security
- SSR Client Authentication
- Edge Functions
- TypeScript Support
- Generating Types
- pg_cron Extension
- Auth JWT Helper
- createClient Reference
Next Steps
For performance optimization and indexing strategies, see supabase-performance-tuning. For deployment pipelines and CI integration, see supabase-ci-integration. For security hardening and policy guardrails, see supabase-security-basics.