This guide walks you through building a multi-tenant authentication system using Next.js 15 and Supabase. You will get isolated user workspaces, organisation-scoped data access, and Row-Level Security policies that enforce tenant boundaries at the database level. Set aside two to three hours.
What You'll Build
- A working Next.js 15 app with Supabase Auth wired up end-to-end
- An
organisationstable with per-tenant data isolation enforced by Row-Level Security - Middleware that resolves the active tenant on every request without a database round-trip
- A membership model that lets one user belong to multiple organisations
- A protected dashboard route that shows only the current tenant's data
Prerequisites
- Node.js 20+ and pnpm 9 installed locally
- A free Supabase account at supabase.com
- Basic familiarity with Next.js App Router and TypeScript
- Supabase CLI installed (
npm i -g supabase@latest)
Step 1: Scaffold the Next.js Project
Start with a clean Next.js 15 install. Use the App Router and TypeScript from the beginning.
pnpm create next-app@latest my-saas --typescript --app --tailwind --eslint
cd my-saas
Then install the Supabase client and the official Next.js server utilities:
pnpm add @supabase/supabase-js @supabase/ssr
Expected result: A Next.js 15 project with app/ directory structure, TypeScript config, and Supabase packages ready.
Common pitfall: Do not use @supabase/auth-helpers-nextjs. That package is deprecated as of 2024. The @supabase/ssr package is the current replacement and supports Next.js 15 middleware correctly.
Step 2: Create the Supabase Project and Set Environment Variables
Go to supabase.com/dashboard and create a new project. Choose a region close to your users. For Australian SaaS products, ap-southeast-2 (Sydney) is a good default.
Once your project is ready, copy the Project URL and the anon public key. Add them to a .env.local file at the project root:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Common pitfall: Never expose your service_role key in client-side code. That key bypasses Row-Level Security entirely.
Step 3: Create Supabase Client Helpers
You need two separate client factories: one for Server Components and one for browser use. Create lib/supabase/server.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
Create lib/supabase/client.ts for browser usage:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Expected result: Two clean factory functions. Server components use cookies. The browser client uses local storage automatically.
Step 4: Design the Multi-Tenant Database Schema
Why does the schema structure matter so much?
Getting the schema right here prevents you from rewriting migrations later. The core idea is that every row of user data belongs to an organisation, and Supabase's Row-Level Security checks that membership on every query.
Run the following SQL in your Supabase SQL Editor:
-- Organisations table
create table public.organisations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
created_at timestamptz default now()
);
-- Memberships join table
create table public.memberships (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
org_id uuid references public.organisations(id) on delete cascade not null,
role text not null default 'member',
created_at timestamptz default now(),
unique(user_id, org_id)
);
-- Example tenant-scoped data table
create table public.projects (
id uuid primary key default gen_random_uuid(),
org_id uuid references public.organisations(id) on delete cascade not null,
name text not null,
created_at timestamptz default now()
);
What indexes should you add?
Add indexes on the foreign keys that Row-Level Security will query on every request. Without them, RLS policy lookups cause full table scans.
create index on public.memberships(user_id);
create index on public.memberships(org_id);
create index on public.projects(org_id);
Step 5: Apply Row-Level Security Policies
Enable RLS on every table that holds tenant data. Then write policies that restrict reads and writes to members of the relevant organisation.
-- Enable RLS
alter table public.organisations enable row level security;
alter table public.memberships enable row level security;
alter table public.projects enable row level security;
-- Users can see orgs they belong to
create policy "members can view their org"
on public.organisations for select
using (
exists (
select 1 from public.memberships
where memberships.org_id = organisations.id
and memberships.user_id = auth.uid()
)
);
-- Users can see only their own memberships
create policy "users can view own memberships"
on public.memberships for select
using (user_id = auth.uid());
-- Users can view projects in their orgs
create policy "members can view org projects"
on public.projects for select
using (
exists (
select 1 from public.memberships
where memberships.org_id = projects.org_id
and memberships.user_id = auth.uid()
)
);
-- Only org members can insert projects
create policy "members can create projects"
on public.projects for insert
with check (
exists (
select 1 from public.memberships
where memberships.org_id = projects.org_id
and memberships.user_id = auth.uid()
)
);
Expected result: A user querying projects will only ever receive rows from organisations they are a member of, even if they manually pass a different org_id.
Common pitfall: If you forget to enable RLS on a table, all policies are ignored and the table is open to any authenticated user. Always check the RLS toggle in the Supabase Table Editor after running migrations.
Step 6: Write the Middleware to Resolve the Active Tenant
Create middleware.ts at the project root. This file runs on every request and refreshes the Supabase session.
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return request.cookies.getAll() },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
if (!user && !request.nextUrl.pathname.startsWith('/login')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Expected result: Unauthenticated users are redirected to /login on every protected route. Sessions refresh silently on each request without an extra database call.
Step 7: Build the Active Tenant Context
Users can belong to multiple organisations. You need a way to track the active one. Store the active org_id in a cookie after login so each server request can resolve it without a query.
Create a server action in app/actions/set-active-org.ts:
'use server'
import { cookies } from 'next/headers'
export async function setActiveOrg(orgId: string) {
const cookieStore = await cookies()
cookieStore.set('active_org_id', orgId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
}
Then read it in any Server Component:
import { cookies } from 'next/headers'
export async function getActiveOrgId(): Promise {
const cookieStore = await cookies()
return cookieStore.get('active_org_id')?.value
}
Step 8: Protect a Dashboard Route
Create app/dashboard/page.tsx to verify the full system works end to end:
import { createClient } from '@/lib/supabase/server'
import { getActiveOrgId } from '@/app/actions/set-active-org'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const orgId = await getActiveOrgId()
if (!orgId) redirect('/select-org')
const { data: projects } = await supabase
.from('projects')
.select('id, name, created_at')
.eq('org_id', orgId)
.order('created_at', { ascending: false })
return (
Projects
{projects?.map(p => (
- {p.name}
))}
)
}
Expected result: The page renders only the projects belonging to the active organisation. If the user switches organisations via setActiveOrg() and refreshes, they see only that organisation's projects.
At Lenka Studio, this pattern is a foundation we use for SaaS products built for clients in Singapore and Australia. The RLS layer means tenant isolation is enforced at the database, not scattered across application logic.
Step 9: Test Tenant Isolation
How do you confirm RLS is actually blocking cross-tenant access?
Create two test users and two test organisations in the Supabase dashboard. Add User A to Org 1 and User B to Org 2. Insert a project row for each org.
Sign in as User A and query projects directly in the Supabase SQL Editor using the anon key with User A's JWT. You should receive only Org 1's project. Then repeat with User B's JWT. If any rows from the other tenant appear, a policy is misconfigured.
You can also use the Supabase Row Level Security testing tool in the dashboard under Authentication > Policies > Test, available as of Supabase v2.0 (2025).
Common pitfall: If your test user is also the database owner, RLS does not apply. Always test with non-owner accounts.
Frequently Asked Questions
Does this work if a user belongs to more than one organisation?
Yes. The memberships table supports many-to-many relationships between users and organisations. The active tenant cookie determines which organisation's data loads on any given request. Users switch tenants by calling setActiveOrg() with a different org_id.
What if a user has no organisation when they first sign up?
Redirect them to an onboarding route that creates an organisation and inserts the first membership row in a single database transaction. Use a Supabase Edge Function or a Next.js server action to handle this atomically.
Is Row-Level Security fast enough for production traffic?
RLS adds a small overhead per query, typically under 2ms on indexed tables. The indexes you added in Step 4 keep policy lookups on the memberships table to a single index scan. Supabase's PgBouncer connection pooling handles concurrent requests at production scale without issues.
How is this different from using Clerk with organisations?
Clerk handles the UI and JWT side of multi-tenancy. This guide handles the database side using Supabase RLS. The two approaches are compatible. You can use Clerk for authentication and still apply RLS policies in Supabase by passing the Clerk JWT to the Supabase client. The isolation logic lives at the Postgres layer either way.
Can I add role-based permissions inside an organisation?
Yes. The memberships.role column holds the user's role within that organisation. Extend your RLS policies to check memberships.role = 'admin' for write operations. This gives you both tenant isolation and fine-grained permission control without a separate permissions library.
Next Steps
You now have a production-ready multi-tenant auth system with database-enforced isolation. From here, you can add invite-based onboarding so users can join an organisation via a signed email link, wire up Stripe billing with per-organisation subscription plans, or add audit logging by inserting to a logs table inside your RLS policies.
If you are building a SaaS product and want an experienced team to architect or review your data model, the engineers at Lenka Studio work on exactly these kinds of builds. Get in touch and share what you are building.




