Skip to main content

Feature deep-dive · SaaSForge Core

Multi-tenant architecture for Next.js B2B SaaS

Multi-tenant architecture for Next.js B2B SaaS

Most Next.js SaaS templates fake multi-tenancy with a `team_id` column bolted onto a single-tenant demo. That works for a launch screenshot and falls over the first time security review asks where isolation is enforced. Real multi-tenancy is a database-level concern — Postgres Row Level Security policies attached to every tenant-scoped table — paired with auth flows that put workspace membership in the request context.

What 'workspaces as the isolation boundary' means

A workspace is the unit of customer ownership: one paying account, one set of billing, one set of members, one set of data. Every domain table (projects, documents, comments, settings) carries a `workspace_id` foreign key. The app never queries a domain table without scoping to a workspace; the database refuses to return rows the current user's workspace membership doesn't grant.

This pattern is different from 'pool tenancy with a tenant_id column' on two axes: where the check lives (in Postgres, not in application code) and what happens on a missing scope (the query returns zero rows, not a leaked row from another tenant). The first axis matters for code review confidence; the second matters for audit and incident response.

Postgres RLS as the enforcement layer

Supabase Auth sets a JWT claim that names the authenticated user. A small Postgres function reads that JWT and joins the user against the `workspace_members` table to derive the active workspace. RLS policies on every domain table reference that function. The policy is pinned to the database, not the ORM — Drizzle, Prisma, raw SQL all see the same isolation.

The trade-off is that schema changes must keep RLS policies up to date. Adding a new tenant-scoped table means writing the corresponding policy; SaaSForge Core ships migrations that pair table and policy in the same SQL file so this stays mechanical.

RLS policy on a tenant-scoped table
create policy "members read own workspace projects"
  on projects for select
  using (
    workspace_id in (
      select workspace_id
      from workspace_members
      where user_id = (auth.jwt() ->> 'sub')::uuid
    )
  );

Four-role RBAC and what each role can do

Most B2B SaaS converges on the same role surface: an Owner who controls billing and ownership transfer, an Admin who manages members and settings, a Member who works on the product, and a Viewer who reads but does not write. SaaSForge Core ships these four roles with explicit permission checks attached to mutations.

The permission system is granular enough that you can define new actions (e.g., `documents.delete`, `billing.read`) without rewriting the role table. Roles map to permission sets; the UI hides what the user cannot do, and the API rejects what the UI hides. Both checks exist because hiding-only UIs leak through API testing tools.

What enterprise-shaped buyers ask about

Procurement reviews tend to ask the same questions: Is two-factor available? Is SSO supported? Are API keys rotated and masked? Are audit logs immutable? Is impersonation gated?

SaaSForge Core ships TOTP 2FA via Supabase Auth, SAML SSO hooks for Okta/Azure AD/Google Workspace, API keys with secret masking after creation, audit log rows on every mutation (actor, resource, metadata, timestamp), support impersonation with explicit allowlists, and optional IP allowlisting for workspace access. None of this is bolted on after the demo — they are first-class patterns in the codebase.

Frequently asked

Is RLS slow at scale?
Postgres RLS is implemented as a query rewrite — the planner injects the policy predicate into the query. With proper indexes on `workspace_id` and the join through `workspace_members`, RLS overhead is single-digit percent on typical OLTP workloads. The slow path is policies that can't use indexes; SaaSForge Core's policies are designed around this.
Why not enforce isolation in the application layer?
Application-layer isolation works until someone forgets a `where` clause in a hand-rolled query, an internal admin tool skips the standard ORM, or a debugging script pulls 'just this one row' across tenants. RLS makes the enforcement layer mandatory. The application can still add checks; the database is the backstop.
How is workspace membership modeled?
A `workspace_members` table joins users to workspaces with a role column. Membership is the source of truth for permission checks; the JWT carries the user identity but not the role (so revoked roles take effect immediately, not at next token rotation).
Can I add custom roles?
Yes. The role surface is data-driven — adding a role means inserting a row in the role table and mapping it to a permission set. The UI surfaces are template-driven so new roles show up in the workspace settings without component changes.
Ships in SaaSForge Core

See SaaSForge Core. Skip the deliberation.

Full source code. Lifetime updates. Polar Merchant-of-Record checkout. Private GitHub repo on purchase.