Most Supabase tutorials stop at a single public schema with basic RLS policies. That works for a weekend project, but production applications with multiple tenants, complex permission models, or domain separation need a more sophisticated approach. This guide covers multi-schema architecture in Supabase: when to use it, how to implement it, and the specific patterns that keep your data organized as your application scales to thousands of users across multiple product areas.
The foundation is understanding PostgreSQL schemas. A schema is a namespace within a database. The default schema in Supabase is "public." Every table you create in the dashboard goes into public. But PostgreSQL supports unlimited schemas, and using them strategically gives you clean boundaries between different concerns.
When to use multiple schemas. Pattern one: domain separation. If your app has distinct domains (billing, content, analytics, user management), each domain gets its own schema. CREATE SCHEMA billing; CREATE SCHEMA content; CREATE SCHEMA analytics;. Tables in the billing schema: billing.subscriptions, billing.invoices, billing.payment_methods. Tables in the content schema: content.posts, content.comments, content.media. This keeps your table list manageable and makes it obvious which domain each table belongs to.
Pattern two: multi-tenant isolation. For B2B SaaS where each customer (tenant) needs logical data separation, you have two options. Option A: shared tables with a tenant_id column and RLS policies that filter by tenant. This is simpler but requires discipline with every query. Option B: per-tenant schemas (tenant_abc, tenant_xyz) with identical table structures. This provides stronger isolation but adds operational complexity. For most applications, Option A with robust RLS is the right choice. Option B is for industries with strict data isolation requirements (healthcare, finance, government).
Setting up a multi-schema Supabase project. Step one: create schemas via migration. Use the Supabase CLI: supabase migration new add_schemas. In the migration file, create your schemas and grant appropriate permissions. You must grant usage on the schema to the authenticated and anon roles, and grant select/insert/update/delete on tables within those schemas.
Step two: expose schemas through the API. By default, Supabase only exposes the public schema through PostgREST. To expose additional schemas, go to your Supabase dashboard, navigate to Settings, then API, and add your custom schemas to the "Exposed schemas" list. Alternatively, set this in your config.toml for local development.
Step three: query non-public schemas from your application. When using the Supabase client, specify the schema: supabase.schema('billing').from('subscriptions').select('*'). This tells PostgREST to query the billing schema instead of public.
RLS across schemas. Each table in every schema needs its own RLS policies. Enable RLS on creation: ALTER TABLE billing.subscriptions ENABLE ROW LEVEL SECURITY;. Policies can reference functions and data from other schemas. A common pattern: store user roles in public.user_roles and reference them in billing schema policies: CREATE POLICY "Admins can view all subscriptions" ON billing.subscriptions FOR SELECT USING (EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = auth.uid() AND role = 'admin')).
Cross-schema foreign keys work seamlessly. billing.subscriptions can reference public.profiles with a foreign key. PostgreSQL handles this natively. However, be mindful of cascade deletes across schemas. Document your cross-schema dependencies clearly.
Database functions and triggers across schemas. Create functions in the schema they logically belong to. A function that calculates invoice totals belongs in the billing schema: CREATE FUNCTION billing.calculate_invoice_total(invoice_id UUID). Triggers can reference functions from any schema. A trigger on content.posts could call a function in analytics.track_post_created().
Migration strategy. Keep migrations organized by schema. Name them descriptively: 20240101_create_billing_schema.sql, 20240102_billing_add_subscriptions.sql. Run all migrations through the Supabase CLI to maintain a single source of truth. Never make schema changes directly in the dashboard for production projects.
Performance considerations. Indexes work the same way across schemas. Create indexes on frequently queried columns in every schema. Use EXPLAIN ANALYZE to verify query plans are using your indexes. For cross-schema joins, ensure both sides of the join have appropriate indexes.
This architecture scales to hundreds of tables across dozens of schemas while remaining maintainable. The key is consistency: same naming conventions, same RLS patterns, same migration workflow across every schema.
Continue Reading
This content is available with BliniBot Pro or as an individual purchase.