Skip to content
All guides
GuideIntermediatesupabasemulti-tenantsaasrls

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.

240 min to complete 12 min read

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.

0001_init.sqlsql
-- 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.

actions/onboard.tsts
"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.

app/[tenant]/projects/page.tsxts
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 memberships and 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 subscriptions table keyed on tenant_id is the boring-and-correct approach.