DEV Community

Cover image for How I Built Multi-Tenant SaaS Architecture with Next.js, Supabase, and PostgreSQL RLS
Mohamed Sadok
Mohamed Sadok

Posted on

How I Built Multi-Tenant SaaS Architecture with Next.js, Supabase, and PostgreSQL RLS

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
Enter fullscreen mode Exit fullscreen mode

A user doesn't directly own the application's business data.

Instead:

User → Membership → Organization → Resource
Enter fullscreen mode Exit fullscreen mode

This distinction becomes important when a user belongs to multiple organizations.

For example:

Alice
 ├── Acme Inc.       → Admin
 ├── Startup Labs    → Member
 └── Example Corp.   → Owner
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The database becomes another security boundary.


Organizations and Memberships

A simplified database model looks like this:

profiles
   │
   │
   ▼
organization_members
   │
   ▼
organizations
   │
   ├── projects
   └── invitations
Enter fullscreen mode Exit fullscreen mode

The membership table connects users to organizations.

Conceptually:

organization_members

user_id
organization_id
role
created_at
Enter fullscreen mode Exit fullscreen mode

This allows the same user to have different roles in different organizations.

For example:

Acme Inc.
Mohamed → OWNER

Startup Labs
Mohamed → MEMBER
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The invitation record contains information such as:

organization_id
email
token
role
expires_at
accepted_at
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Every project belongs to an organization.

Now imagine a user belonging to:

organization_id = abc
Enter fullscreen mode Exit fullscreen mode

The user should only be able to access:

projects.organization_id = abc
Enter fullscreen mode Exit fullscreen mode

and nothing belonging to:

organization_id = xyz
Enter fullscreen mode Exit fullscreen mode

The RLS policy expresses this relationship at the database level.

Conceptually:

Current User
     │
     ▼
Is member of organization?
     │
   ┌─┴─┐
  YES  NO
   │    │
   ▼    ▼
 ALLOW DENY
Enter fullscreen mode Exit fullscreen mode

This is fundamentally different from simply hiding records in the frontend.


Frontend Checks Are Not Security

This is worth emphasizing.

This:

{isAdmin && <DeleteButton />}
Enter fullscreen mode Exit fullscreen mode

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  │
└──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

That requires reading:

organization_members
Enter fullscreen mode Exit fullscreen mode

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
   ↓
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.          ▼ │
└──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Opening it:

Acme Inc.
Startup Labs
Example Corp.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then security is enforced at multiple levels:

UI
 ↓
Server Authorization
 ↓
PostgreSQL RLS
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
raju_dandigam profile image
Raju Dandigam •

Treating membership as the link between user and organization is a solid foundation, and I appreciate the warning about recursive RLS checks. The SECURITY DEFINER helper 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.

Collapse
 
mohamed_sadok_42edff3a1b3 profile image
Mohamed Sadok •

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.