# What You’ll Build#
This guide shows a practical, production-grade strategy for Next.js Supabase row level security multitenant apps using the Next.js App Router. You’ll design tables, implement RLS policies with roles, and use safe server-side access patterns that keep tenant data isolated even when your queries get complex.
You’ll also learn how teams accidentally break isolation with service role misuse and leaky joins, plus a deployment checklist you can copy into your runbook.
For broader context on multi-tenant system design, see our architecture deep dive: Next.js multi-tenant SaaS architecture guide. If you’re still deciding on auth, this guide pairs well with: Next.js authentication guide for NextAuth, Clerk, and Supabase. For an end-to-end baseline, see: Next.js Supabase SaaS starter architecture.
# Multi-Tenant RLS Strategy in One Page#
There are two common tenancy models:
| Model | Description | Pros | Cons | Best for |
|---|---|---|---|---|
| Single database, shared tables | Every row has tenant_id (or organization_id) and RLS isolates access | Simple ops, lower cost, easy analytics | Policies must be correct everywhere | Most B2B SaaS |
| Database per tenant (or schema per tenant) | Each tenant has separate DB or schema | Strong isolation boundary | Complex ops, migrations, cost | Regulated or very large tenants |
This guide focuses on shared tables + strict RLS, because it’s the typical Supabase fit and scales well until you hit enterprise isolation requirements.
A robust approach has three pillars:
- 1Tenant membership is stored in the database, not hardcoded in JWT claims.
- 2Every tenant-scoped table has
tenant_idand RLS enabled. - 3App code never “filters by tenant” as the primary security boundary. The database is the boundary.
🎯 Key Takeaway: Your app’s
WHERE tenant_id = ...is for performance and clarity, not security. RLS is the security control.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 14+ or 15+ | App Router patterns used |
| Supabase | Postgres 15+ (managed) | RLS, auth, policies |
| supabase-js | 2.x | Server-side client patterns |
| Auth | Supabase Auth or external provider | Must provide a valid user JWT to Supabase |
If you use Clerk or NextAuth, your integration must still end with Supabase receiving a JWT it can validate as the request user. If that’s not true, your “RLS” becomes “trust the app server,” which defeats the point.
# Table Design for Multi-Tenant SaaS#
Core Entities#
A common B2B SaaS data model uses organizations, memberships, and tenant-scoped resources.
| Table | Purpose | Tenant-scoped | Notes |
|---|---|---|---|
organizations | Tenant record | Yes | One row per tenant |
organization_members | User belongs to organization, with a role | Yes | Your primary authorization primitive |
projects | Example resource | Yes | Has organization_id |
tickets | Example resource | Yes | Has organization_id and maybe project_id |
profiles | User profile data | No | Usually 1 row per user, not tenant-specific |
SQL Schema Example#
Keep the schema explicit: organization_id everywhere, strict FK constraints, and stable IDs.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
create table public.organization_members (
organization_id uuid not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
primary key (organization_id, user_id)
);
create table public.projects (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
name text not null,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index projects_org_idx on public.projects (organization_id);
create table public.tickets (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
project_id uuid references public.projects(id) on delete set null,
title text not null,
body text,
created_by uuid not null references auth.users(id),
created_at timestamptz not null default now()
);
create index tickets_org_idx on public.tickets (organization_id);
create index tickets_project_idx on public.tickets (project_id);Why this matters:
- Composite primary key on membership prevents duplicates.
- Every tenant-scoped table has a direct
organization_idcolumn, even if it also references another tenant-scoped table. - Indexing
organization_idis non-negotiable. Without it, RLS filtering becomes an expensive seq scan under load.
💡 Tip: Denormalize
organization_idonto every tenant-scoped table, even when you can derive it through joins. It improves query performance and makes RLS policies simpler and less error-prone.
# Roles, Claims, and How Supabase Evaluates RLS#
Supabase uses Postgres roles and JWT claims. In practice:
anonandauthenticatedare the roles you’ll use most.auth.uid()returns the current user ID from the JWT.- Policies are evaluated per table, per command (SELECT, INSERT, UPDATE, DELETE).
- If RLS is enabled and there is no matching policy, access is denied.
The key design choice is: where do roles live?
Recommended: Roles Live in organization_members#
Store tenant roles in a table and check it from policies. This supports:
- Users in multiple organizations
- Role changes without token refresh issues
- Auditable membership history if you add logs later
Avoid relying on custom JWT claims for tenant role unless you fully control token issuance and refresh strategy. Claims drift is a real issue, and drift in access control is a security issue.
# Implementing RLS: Policies That Scale With Complexity#
Step 1: Enable RLS on Tenant Tables#
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.projects enable row level security;
alter table public.tickets enable row level security;Also consider forcing it:
alter table public.organizations force row level security;
alter table public.projects force row level security;
alter table public.tickets force row level security;Force RLS is a defensive option that prevents table owners from bypassing policies in certain contexts. Use it if you are disciplined about admin access and migrations.
Step 2: Helper Predicate via SQL Function#
A reusable helper reduces policy duplication. Keep it simple, stable, and safe.
create or replace function public.is_org_member(org_id uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.organization_members m
where m.organization_id = org_id
and m.user_id = auth.uid()
);
$$;You can also add role checks:
create or replace function public.org_role(org_id uuid)
returns text
language sql
stable
as $$
select m.role
from public.organization_members m
where m.organization_id = org_id
and m.user_id = auth.uid()
limit 1;
$$;Step 3: Organization Visibility Policy#
Organizations should be visible only if the user is a member.
create policy org_select_for_members
on public.organizations
for select
to authenticated
using (public.is_org_member(id));Step 4: Membership Policies#
Membership tables are sensitive because they can reveal tenant structure and user lists. Decide what’s allowed.
A common rule:
- Any member can see the membership list for their org (for UI like “Team members”).
- Only owners and admins can add or remove members.
create policy members_select_in_org
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));For inserts, restrict by role. Also lock down user_id assignments to avoid “invite myself as owner” attacks.
create policy members_insert_admin_only
on public.organization_members
for insert
to authenticated
with check (
public.org_role(organization_id) in ('owner', 'admin')
);For deletes:
create policy members_delete_admin_only
on public.organization_members
for delete
to authenticated
using (
public.org_role(organization_id) in ('owner', 'admin')
);You can refine this further, like preventing removal of the last owner. That’s best done in a transaction or a trigger, not only in RLS.
⚠️ Warning: RLS can’t reliably enforce complex cross-row invariants like “at least one owner remains” without careful locking. Implement that logic in a single SQL function or transaction and expose it via RPC.
Step 5: Tenant-Scoped Resource Policies#
For projects:
create policy projects_select_in_org
on public.projects
for select
to authenticated
using (public.is_org_member(organization_id));
create policy projects_insert_in_org
on public.projects
for insert
to authenticated
with check (public.is_org_member(organization_id));
create policy projects_update_in_org
on public.projects
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));
create policy projects_delete_admin_only
on public.projects
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));For tickets, similar pattern:
create policy tickets_select_in_org
on public.tickets
for select
to authenticated
using (public.is_org_member(organization_id));
create policy tickets_insert_in_org
on public.tickets
for insert
to authenticated
with check (public.is_org_member(organization_id));
create policy tickets_update_in_org
on public.tickets
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));Why both using and with check for updates:
usingcontrols which existing rows you can target.with checkcontrols what the row can become after update.- Without
with check, a user might update a row’sorganization_idto move it into a different tenant if your schema allows it.
If organization_id should never change, enforce it:
- Make it immutable at the app level
- Consider a trigger that raises an exception on change
- Or remove update permission entirely for that column via views or RPC
# Next.js App Router: Safe Server-Side Access Patterns#
Pattern 1: Server Components and Server Actions Using the User Session#
Your default should be: use the user’s JWT, call Supabase, let RLS filter.
This requires a server-side Supabase client that can read cookies and attach the session automatically. The exact implementation varies depending on your setup, but the principle is consistent.
A minimal “server client” factory might look like this:
// lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
export function createSupabaseServerClient(accessToken: string) {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ global: { headers: { Authorization: `Bearer ${accessToken}` } } }
);
}Then in a server action, you pass the user token from your auth layer.
'use server';
import { createSupabaseServerClient } from '@/lib/supabase/server';
export async function listProjects(accessToken: string, organizationId: string) {
const supabase = createSupabaseServerClient(accessToken);
const { data, error } = await supabase
.from('projects')
.select('id, name, created_at')
.eq('organization_id', organizationId)
.order('created_at', { ascending: false });
if (error) throw new Error(error.message);
return data;
}The .eq('organization_id', organizationId) is not the security boundary. It reduces rows and improves performance. If a user passes another tenant ID, RLS still blocks access.
Pattern 2: Use RPC for Privileged Multi-Step Operations#
For actions like “invite member,” you often need:
- Validate role
- Insert membership
- Maybe write an audit row
- Maybe send an email job
Doing it across multiple round-trips from Next.js increases race conditions. Prefer a single transaction on the database.
create or replace function public.invite_member(org_id uuid, new_user_id uuid, new_role text)
returns void
language plpgsql
security invoker
as $$
begin
if public.org_role(org_id) not in ('owner', 'admin') then
raise exception 'not allowed';
end if;
insert into public.organization_members(organization_id, user_id, role)
values (org_id, new_user_id, new_role)
on conflict (organization_id, user_id) do update set role = excluded.role;
end;
$$;Call from Next.js using the user session:
'use server';
import { createSupabaseServerClient } from '@/lib/supabase/server';
export async function inviteMember(accessToken: string, orgId: string, userId: string) {
const supabase = createSupabaseServerClient(accessToken);
const { error } = await supabase.rpc('invite_member', {
org_id: orgId,
new_user_id: userId,
new_role: 'member',
});
if (error) throw new Error(error.message);
}Keep it security invoker so RLS and role checks apply to the caller.
ℹ️ Note: Avoid
security definerfor tenant data unless you deeply understand how it can bypass RLS. If you must use it, explicitly enforce tenant checks inside the function and limit returned columns.
Pattern 3: Service Role for Background Jobs Only#
Some workloads legitimately need service role:
- Stripe webhooks syncing subscription status
- Scheduled jobs (daily invoices)
- Admin dashboard that spans all tenants
In Next.js, that should run in server-only routes with strict request authentication, and never for user-scoped reads.
A safe baseline:
- Service role used only in
/api/webhooks/*or internal cron endpoints. - Endpoints protected with signature verification or internal auth.
- All user-facing pages and actions use anon key plus user JWT.
# Common Pitfalls That Break Tenant Isolation#
# Pitfall 1: Using Service Role for “Convenience”#
Developers reach for service role because it “just works” when RLS blocks a query. That’s exactly why it’s dangerous.
If you use service role in any request path that is user-triggered, you have to rebuild authorization in app code perfectly. That’s hard to maintain and easy to regress.
Concrete risk pattern:
- A list endpoint uses service role.
- It “filters by organization_id” from a query param.
- Attacker changes param and reads another tenant.
The fix:
- Use user JWT for tenant-scoped reads.
- Keep service role limited to server-to-server flows.
- Add automated tests that assert cross-tenant reads return zero rows.
# Pitfall 2: Leaky Joins and Missing RLS on Related Tables#
RLS is table-level. If you join a tenant-scoped table to a non-protected table, you can accidentally leak data.
Example scenario:
ticketshas RLS.- You join to
ticket_commentsbut forgot to enable RLS there. - A user selects tickets and gets comments from other orgs because comments table isn’t filtered.
Fix:
- 1Every table containing tenant data must have:
organization_id- RLS enabled
- Policies enforcing membership
- 2Avoid tables that “implicitly belong” to a tenant only through joins. Make tenant ownership explicit.
If you absolutely must derive tenant via joins, enforce it with a view or RPC that applies the join constraint server-side, then lock down direct table access.
# Pitfall 3: Policies That Reference Unindexed Columns#
RLS adds predicates to queries. If your policy checks membership via a subquery, that subquery must be fast.
Ensure you have indexes:
organization_members (organization_id, user_id)is already a primary key in our model- If you query by
user_ida lot, addorganization_members_user_idx (user_id)
Bad performance becomes a security issue when teams “temporarily” disable RLS to ship.
# Pitfall 4: Allowing Updates That Change Tenant Ownership#
If organization_id is editable and your update policy is too permissive, attackers can try to “move” rows between tenants.
Mitigations:
- Make
organization_idnot updatable via RPC or views. - Add a trigger to block changes.
- Use
with checkin UPDATE policies. - Consider
project_idand similar FK fields too, if moving between projects implies access changes.
# Pitfall 5: Not Testing RLS With Real Queries#
RLS bugs don’t show up in unit tests that mock data access.
What you should test:
- User A in org A cannot read or update org B rows.
- User A cannot insert a row for org B.
- Admin-only operations fail for members.
- Joins do not leak cross-tenant data.
Even a small integration test suite catches most regressions.
# Deployment Checklist for Next.js and Supabase RLS#
Use this before staging and production deploys.
| Area | Check | Why it matters |
|---|---|---|
| RLS coverage | RLS enabled on every tenant-scoped table | One missed table can leak data |
| Policies | SELECT, INSERT, UPDATE, DELETE policies defined intentionally | Missing policy means denied or inconsistent behavior |
| Tenant column | Every tenant table includes organization_id and indexes | Simpler policies and predictable performance |
| Membership model | Membership table has PK and role constraint | Prevent duplicate memberships and invalid roles |
| Service role | Not used in user-driven request paths | Avoid accidental full-data access |
| RPC functions | Prefer security invoker, validate role inside function | Prevent bypassing RLS |
| Triggers | Block tenant ID changes where required | Prevent ownership reassignment attacks |
| Logging | Log admin actions and membership changes | Auditing and incident response |
| Environment | Service role key never exposed to client | Treat it like database root |
| Tests | Cross-tenant access tests in CI | Prevent regressions |
| Observability | Monitor slow queries on membership checks | Performance issues push teams to unsafe shortcuts |
💡 Tip: In CI, run a “tenant isolation smoke test” that creates two orgs and two users, inserts data, and asserts every cross-tenant query returns an empty set. This takes minutes and prevents expensive incidents.
# Key Takeaways#
- Design multi-tenant tables with an explicit
organization_idon every tenant-scoped row, backed by indexes for predictable RLS performance. - Store roles in
organization_membersand check membership in RLS policies using helpers likepublic.is_org_member(org_id). - In Next.js App Router, use the user JWT for all tenant-scoped reads and writes so RLS is always the security boundary.
- Use RPC for multi-step operations and keep functions
security invokerunless you have a strong reason and extra safeguards. - Avoid service role in user-triggered code paths and audit your schema for leaky joins by enabling RLS everywhere tenant data exists.
- Ship with a deployment checklist and CI tests that explicitly validate cross-tenant access is impossible.
# Conclusion#
Next.js and Supabase can be a strong stack for multi-tenant SaaS, but only if you treat the database as the enforcement layer. When your tables are designed for tenancy, your RLS policies are consistent, and your Next.js server code uses user sessions instead of service role shortcuts, you get isolation that’s hard to accidentally break.
If you want us to review your schema and policies, or implement a secure multi-tenant foundation with Next.js App Router and Supabase, contact Samioda and we’ll help you ship a production-ready architecture faster.
FAQ
More in Web Development
All →Dynamic Open Graph Images in Next.js: OG Generation, Caching, Fonts, and Edge Runtime Tips
A practical 2026 guide to Next.js dynamic Open Graph images: per-page OG generation, font loading, cache headers, Edge runtime gotchas, and deployment troubleshooting.
Next.js Technical SEO Audit Checklist (App Router): Indexing, Metadata, Core Web Vitals, and Structured Data
A step-by-step Next.js technical SEO audit checklist for the App Router: crawling and indexing controls, metadata and canonicals, sitemaps and robots, pagination, Core Web Vitals, and JSON-LD schema with copy-pasteable code.
Building a React Design System with Design Tokens: Tailwind CSS + Radix UI + TypeScript
A practical 2026 guide to building a React design system with Tailwind and Radix using design tokens, accessible primitives, theming, and reusable packages across multiple apps.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy
A production-ready blueprint for a Next.js App Router + Supabase SaaS starter architecture: auth, Postgres data model, RLS policies, Stripe billing, and multi-tenant organization design with concrete examples.
Next.js Multitenant SaaS Architecture: Tenancy Models, Routing, Auth, and Data Isolation (2026 Guide)
A practical guide to Next.js multitenant SaaS architecture: tenancy models, tenant-aware routing with App Router and middleware, auth patterns, and hardening data isolation to prevent leaks.
Next.js Authentication in 2026: NextAuth vs Clerk vs Supabase (What We Use for Client Projects)
A practical comparison of Next.js authentication options in 2026 — NextAuth, Clerk, and Supabase — across UX, security, cost, setup time, and enterprise requirements, with decision matrices for SaaS, internal tools, and B2B portals.