Skip to main content
Supabase

Multi-tenant Next.js architecture with Postgres RLS

Published May 10, 20267 min read
Multi-tenant Next.js architecture with Postgres RLS

Multi-tenant Next.js architecture with Postgres RLS

The hardest part of multi-tenant SaaS is not the UI for switching workspaces, it is making sure no query, ever, returns rows from the wrong tenant. A bug in the application layer can do that. A correct RLS policy cannot.

Pick a tenant boundary and stick to it

The first decision is what a tenant is. Three common shapes:

  • One user, one tenant. Useful for B2C-shaped SaaS where every user has their own private workspace. Simple, tenant_id == user_id.
  • Workspaces with members. The B2B default. A user belongs to one or more workspaces via a workspace_members join table with a role. Switching workspaces changes the active tenant context.
  • Org-and-projects. A two-level hierarchy where an org owns multiple projects and projects are the actual isolation unit. Heavier; only worth it if customers genuinely operate that way.

Most B2B products want the workspace model. Workspaces become the primary key in URLs (/[workspace]/dashboard), the boundary for billing (one Stripe customer per workspace), and the column you put on every tenant-scoped table.

Make Postgres the enforcement layer

Every tenant-scoped table gets a workspace_id uuid not null references workspaces(id) on delete cascade column, an index on it, and an RLS policy that allows access only when workspace_id matches the current request context.

The current context comes from a Postgres setting set at the start of each request, SET LOCAL app.current_workspace_id = '<uuid>'. Your application reads the session, validates the user belongs to the requested workspace, then sets that local. RLS does the rest.

For Supabase specifically, the same pattern works through JWT claims. Put workspace_id in the JWT, write policies that read auth.jwt() ->> 'workspace_id', and rely on the fact that the JWT cannot be forged client-side. The detailed policy patterns, including the join through workspace_members for users who belong to multiple workspaces, live in /features/postgres-rls-boilerplate.

RLS catches three classes of bug your application code will eventually write:

  1. A new endpoint that forgets the .eq("workspace_id", currentWorkspaceId) filter.
  2. A migration that adds a foreign-key column but forgets the policy.
  3. A junior dev copying a query from a different table and not noticing it skipped the workspace scope.

Without RLS, all three ship to production. With RLS, the query returns zero rows and the bug shows up in dev.

What lives outside the database

A few pieces of the architecture do not fit in RLS:

  • Workspace switching. The active workspace lives in a cookie or URL segment. The middleware reads it, your server actions validate the user belongs to that workspace, and only then is the context set. A user passing ?workspace=other-org-uuid in the URL must hit a "not a member" check before any query runs.
  • Cross-tenant admin views. Support and admin tooling needs to read across tenants. Run those with a separate service_role Postgres role that bypasses RLS, never in user-facing code.
  • Background jobs. A worker processing a queue item needs to know which tenant context to set before running queries. Encode the workspace id in the job payload and set the local at the start of every job.

The full multi-tenant primitives, workspaces, 4-role RBAC, RLS policies, 2FA, audit logs, API keys per workspace, ship in /saasforge-core as a working Next.js 16 app you can read and edit. If your hardest requirement is getting multi-tenant right the first time, that is the shortest path.

Newsletter

Get the BoilerlyKit newsletter

Practical Next.js SaaS launch tips, delivered when we ship something worth sharing.

We respect your inbox. See our privacy policy.