Building the first version of a SaaS application is usually straightforward.
Building the part that makes sure Customer A can never access Customer B's data is where things get serious.
A typical B2B SaaS eventually needs:
- Authentication
- Organizations
- Team members
- Roles and permissions
- Invitations
- Protected routes
- Tenant-scoped data
- Database-level security
You can implement all of this incrementally.
But there is a problem with that approach:
Multi-tenancy affects almost every layer of the application.
So I wanted to build a reusable architecture where tenant isolation was part of the foundation rather than something added after the application had already grown.
This article explains the architecture I used with Next.js, Supabase, TypeScript, and PostgreSQL Row-Level Security.
The basic architecture
The core relationship is:
User
│
▼
Organization Membership
│
▼
Organization
│
├── Projects
├── Invitations
└── Other Tenant Resources
A user doesn't directly own the application's business data.
Instead:
User → Membership → Organization → Resource
This distinction becomes important when a user belongs to multiple organizations.
For example:
Alice
├── Acme Inc. → Admin
├── Startup Labs → Member
└── Example Corp. → Owner
The same authenticated user can therefore work across multiple tenants without creating separate accounts.
Why Row-Level Security Matters
One of the easiest mistakes in a multi-tenant application is relying entirely on application-level filtering.
Imagine:
const projects = await getProjects()
The developer is expected to remember that every query must be scoped to the current organization.
Usually that becomes something like:
const projects = await getProjects(organizationId)
This is better.
But there is still a problem.
What happens when somebody forgets the filter?
Or introduces a new API endpoint six months later?
Or accidentally exposes a query through another code path?
This is why I wanted PostgreSQL itself to enforce the tenant boundary.
The goal becomes:
Application
│
▼
PostgreSQL
│
▼
RLS Policy
│
├── User belongs to organization → ALLOW
│
└── User does not belong → DENY
The database becomes another security boundary.
Organizations and Memberships
A simplified database model looks like this:
profiles
│
│
▼
organization_members
│
▼
organizations
│
├── projects
└── invitations
The membership table connects users to organizations.
Conceptually:
organization_members
user_id
organization_id
role
created_at
This allows the same user to have different roles in different organizations.
For example:
Acme Inc.
Mohamed → OWNER
Startup Labs
Mohamed → MEMBER
The role belongs to the membership, not globally to the user.
That distinction makes the system much more flexible.
RBAC
The starter uses three basic roles:
OWNER
ADMIN
MEMBER
A simplified permission model could look like:
| Permission | Owner | Admin | Member |
|---|---|---|---|
| View projects | ✓ | ✓ | ✓ |
| Create projects | ✓ | ✓ | ✓ |
| Manage members | ✓ | ✓ | — |
| Change roles | ✓ | ✓ | — |
| Remove members | ✓ | ✓ | — |
| Manage organization | ✓ | — | — |
The important thing isn't the specific roles.
It's keeping authorization centralized instead of scattering checks throughout the application.
For example:
if (!can(user, "members.update")) {
throw new Error("Forbidden")
}
As the application grows, additional roles and permissions can be introduced without rewriting every feature.
Invitations
Invitations are another part of multi-tenancy that looks deceptively simple.
The expected workflow is:
Admin
│
▼
Create invitation
│
▼
Generate token
│
▼
Send invitation
│
▼
User opens link
│
▼
Verify invitation
│
▼
Create membership
The invitation record contains information such as:
organization_id
email
token
role
expires_at
accepted_at
The implementation also verifies that the email associated with the invitation matches the account accepting it.
That prevents a forwarded invitation link from automatically granting access to the wrong account.
The invitations in this implementation expire after seven days.
The Important Part: RLS
Consider a projects table:
projects
id
organization_id
name
description
created_at
Every project belongs to an organization.
Now imagine a user belonging to:
organization_id = abc
The user should only be able to access:
projects.organization_id = abc
and nothing belonging to:
organization_id = xyz
The RLS policy expresses this relationship at the database level.
Conceptually:
Current User
│
▼
Is member of organization?
│
┌─┴─┐
YES NO
│ │
▼ ▼
ALLOW DENY
This is fundamentally different from simply hiding records in the frontend.
Frontend Checks Are Not Security
This is worth emphasizing.
This:
{isAdmin && <DeleteButton />}
is useful.
But it isn't security.
A user can bypass the interface and send a request directly.
That's why I prefer multiple layers:
┌──────────────────────────┐
│ UI checks │
├──────────────────────────┤
│ Server authorization │
├──────────────────────────┤
│ PostgreSQL RLS policies │
└──────────────────────────┘
The UI controls the experience.
Server-side authorization controls the operation.
RLS protects the data.
Each layer has a different responsibility.
Avoiding Recursive RLS Policies
One problem I encountered was recursive policy evaluation.
Suppose a project policy checks:
Is the user a member of this organization?
That requires reading:
organization_members
But if organization_members also has RLS policies, the database can end up evaluating policies that depend on other policies.
Conceptually:
projects
↓
organization_members
↓
RLS
↓
organization_members
↓
...
To avoid this, the architecture uses carefully controlled SECURITY DEFINER helper functions for membership checks.
The result is a reusable authorization primitive that can be referenced by multiple RLS policies without creating recursive policy evaluation.
Next.js Authentication
The application uses Supabase Auth with Next.js server-side authentication.
The important part is keeping authentication state available correctly across server and browser contexts.
The flow is roughly:
Browser
│
▼
Supabase Auth
│
▼
Session Cookie
│
▼
Next.js Server
│
▼
Authenticated User
Protected routes can then determine:
Who is the user?
↓
Which organizations do they belong to?
↓
Which organization are they currently accessing?
↓
What can they do?
This makes the organization context explicit rather than relying on client-side state alone.
Tenant-Scoped Projects
Projects are used as the example tenant resource.
A project belongs to exactly one organization:
Acme Inc.
├── Website Redesign
├── Mobile Application
└── Marketing Platform
Startup Labs
├── AI Assistant
└── Analytics Dashboard
When the current organization changes, the project dataset changes accordingly.
The application doesn't need a separate codebase for each tenant.
The same application handles all organizations.
Organization Switching
The organization selector is another important part of the UX.
A user may belong to several organizations:
┌──────────────────────┐
│ Acme Inc. ▼ │
└──────────────────────┘
Opening it:
Acme Inc.
Startup Labs
Example Corp.
Selecting an organization changes the tenant context.
The application then loads resources belonging to that organization.
The database still enforces the final access boundary.
Application Structure
The resulting route structure is intentionally simple:
/
├── login
├── signup
├── dashboard
│
├── org/[orgId]
│ ├── members
│ ├── projects
│ └── invites
│
├── invite/[token]
│
└── auth/callback
This creates a clear distinction between:
- Public pages
- Authentication
- Organization context
- Tenant resources
- Invitation flows
It also leaves plenty of room for additional modules.
Why I Chose Supabase
The goal wasn't to assemble dozens of services.
Supabase gives the project several important pieces in one ecosystem:
Supabase
├── Authentication
├── PostgreSQL
└── Row-Level Security
That combination is particularly useful for multi-tenant applications because authentication and database authorization can work together.
Instead of building a separate authentication system and then trying to synchronize it with a separate database authorization layer, the application can use the authenticated identity directly in PostgreSQL security policies.
What This Architecture Doesn't Solve
This isn't a complete SaaS business.
There are deliberately no:
- Stripe subscriptions
- OAuth providers
- File storage
- Background jobs
- Audit logs
- Usage-based billing
- Automated test suite
Those features depend heavily on the actual product being built.
A CRM may need billing and audit logs.
An AI application may need usage limits and model providers.
A project management platform may need file storage and background processing.
The foundation should stay generic.
The Takeaway
The biggest lesson from building this wasn't a specific Supabase feature.
It was this:
Multi-tenancy should be treated as an architectural boundary, not simply a database column.
A scalable model starts with:
User
↓
Membership
↓
Organization
↓
Tenant Resource
Then security is enforced at multiple levels:
UI
↓
Server Authorization
↓
PostgreSQL RLS
This gives you a much stronger foundation for building B2B SaaS applications.
Instead of repeatedly solving authentication, organizations, roles, invitations, and tenant isolation, you can start with those pieces already established and focus on the actual product.
What I Built
I packaged this architecture into a reusable Next.js + Supabase Multi-Tenant SaaS Starter.
It includes:
- Next.js 16
- TypeScript
- Supabase Auth
- PostgreSQL
- Row-Level Security
- Organizations
- RBAC
- Invitations
- Tenant-scoped Projects CRUD
- Member management
- DDEV support
- Vercel deployment support
- Database migrations
- Documentation
- Static demo
You can explore the working demo here:
https://demo-supabase-multi-tenant-saa-s-st.vercel.app/
The article above is intentionally focused on the architecture rather than the product. If you are building a multi-tenant SaaS, the important part is understanding the security model and tenant boundaries before the application grows around them.










Top comments (2)
Treating membership as the link between user and organization is a solid foundation, and I appreciate the warning about recursive RLS checks. The
SECURITY DEFINERhelper is a powerful escape hatch; have you tested the same cross-tenant denial cases through every server path, including any service-role or background-job client that could bypass RLS? That's often where an otherwise sound browser-facing policy quietly loses its protection.Thanks! That’s a great point. I’d definitely test cross-tenant access through all server paths too, especially service-role and background jobs, since those can bypass RLS. That’s an area I want to cover with automated tests as well.