Build a Multi-Tenant SaaS on Supabase in a Day
From blank Supabase project to working multi-tenant SaaS: tenants, members, RLS, invites, and an admin tool pointed at it. The opinionated playbook for 2026.
A day is enough for a working multi-tenant SaaS skeleton with proper isolation, invite flow, and an admin tool. Not a finished product; the bones that everything else builds on.
Hour 1: schema + RLS
The classic shared-table + tenant_id pattern. The most important rule: every business entity carries a tenant_id and RLS filters on it.
-- Tenants (organisations)
CREATE TABLE public.tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Memberships link auth.users to tenants
CREATE TABLE public.memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
-- Helper function: tenant ids the current user belongs to
CREATE OR REPLACE FUNCTION public.my_tenants()
RETURNS SETOF uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT tenant_id FROM memberships WHERE user_id = auth.uid()
$$;
-- A business entity: projects
CREATE TABLE public.projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_idx ON projects (tenant_id);
-- RLS
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Members read their memberships" ON memberships FOR SELECT TO authenticated
USING (user_id = auth.uid());
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Members read projects" ON projects FOR SELECT TO authenticated
USING (tenant_id IN (SELECT public.my_tenants()));
CREATE POLICY "Members write projects" ON projects FOR ALL TO authenticated
USING (tenant_id IN (SELECT public.my_tenants()))
WITH CHECK (tenant_id IN (SELECT public.my_tenants()));Hour 2: invite + onboarding
First user creates a tenant when they sign up. Subsequent users join via invite. Both flows are server actions that hit the Supabase Admin API.
"use server";
import { createClient } from "@supabase/supabase-js";
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
export async function createTenant(name: string, slug: string, ownerId: string) {
const { data: tenant, error } = await admin
.from("tenants")
.insert({ name, slug })
.select()
.single();
if (error) throw error;
// Owner becomes the first member, with the owner role
await admin.from("memberships").insert({
tenant_id: tenant.id,
user_id: ownerId,
role: "owner",
});
return tenant;
}
export async function inviteToTenant(
tenantId: string,
email: string,
invitedBy: string,
) {
// Use GoTrue admin invite (sends a magic link)
const { data, error } = await admin.auth.admin.inviteUserByEmail(email, {
data: { invited_to_tenant: tenantId, invited_by: invitedBy },
});
if (error) throw error;
return data;
}Hour 3: app routes
Three routes for the skeleton: /login, /onboarding, and /[tenant]/projects. Use the Supabase JS client on the browser side (with the anon key); RLS does the authz.
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export default async function ProjectsPage({
params,
}: {
params: { tenant: string };
}) {
const c = cookies();
const sb = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { get: (n) => c.get(n)?.value } },
);
const { data: tenant } = await sb
.from("tenants")
.select("id, name")
.eq("slug", params.tenant)
.single();
// RLS already restricts; we don't need to filter on tenant_id again,
// but doing it explicitly is good defence in depth.
const { data: projects } = await sb
.from("projects")
.select("id, name")
.eq("tenant_id", tenant!.id)
.order("created_at", { ascending: false });
return (
<ul>
{projects?.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Hour 4: admin tool
Don't write an admin from scratch. Point Suparbase at this project: paste the URL and service_role key, and you get inline editing, audit logging, the AI chat assistant, and an RLS debugger. The skeleton you just shipped has a real admin in front of it within minutes.
What to do next
- Add a role column to
membershipsand gate writes on it (owner vs member). - Add an audit log table; have your write paths fire entries into it (or read our RLS guide for the per-table audit pattern).
- Add billing. Stripe + a
subscriptionstable keyed ontenant_idis the boring-and-correct approach.