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_membersjoin 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:
- A new endpoint that forgets the
.eq("workspace_id", currentWorkspaceId)filter. - A migration that adds a foreign-key column but forgets the policy.
- 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-uuidin 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_rolePostgres 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.
Keep reading on related features
Postgres RLS multi-tenant boilerplate for Next.js + Supabase
A Postgres RLS boilerplate puts tenant isolation in the database, not the application. Every domain table carries a workspace_id…
Multi-tenant architecture for Next.js B2B SaaS
Multi-tenant architecture in a Next.js SaaS means workspaces are the isolation boundary in Postgres (enforced via RLS), users…
Ship this with a Boilerlykit template
Skip the wiring. Each template ships the patterns from this article as production code with MDX docs.