Multi-tenant App
This guide builds a multi-tenant SaaS application where each organization has isolated data, role-based access, and tenant-scoped storage — using Auth, Data, Storage, and Functions together.
What you’ll build:
- Organizations with owner/admin/member roles
- JWT claims that carry
org_idfor RLS - Org-scoped CRUD with invitation flow
- Per-org storage prefixes
Prerequisites: Running Reactor server, frontend app, understanding of RLS.
1. Schema design
Section titled “1. Schema design”-- migrations/010_multi_tenant.sql
-- OrganizationsCREATE TABLE organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, plan TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT now());
-- Membership with rolesCREATE TABLE org_members ( org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, user_id UUID NOT NULL, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')), joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, user_id));
-- InvitationsCREATE TABLE org_invitations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, email TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', token TEXT UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'), expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '7 days', accepted_at TIMESTAMPTZ, created_by UUID NOT NULL);
-- Tenant-scoped resource (example: projects)CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE INDEX projects_org_idx ON projects(org_id);
-- Enable RLS on all tenant tablesALTER TABLE organizations ENABLE ROW LEVEL SECURITY;ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;ALTER TABLE org_invitations ENABLE ROW LEVEL SECURITY;ALTER TABLE projects ENABLE ROW LEVEL SECURITY;2. RLS policies
Section titled “2. RLS policies”Policies reference JWT claims set by Auth after org selection:
-- Helper: extract claims from JWTCREATE OR REPLACE FUNCTION auth_org_id() RETURNS UUID AS $$ SELECT (current_setting('request.jwt.claims', true)::json->>'org_id')::uuid;$$ LANGUAGE sql STABLE;
CREATE OR REPLACE FUNCTION auth_user_id() RETURNS UUID AS $$ SELECT (current_setting('request.jwt.claims', true)::json->>'sub')::uuid;$$ LANGUAGE sql STABLE;
CREATE OR REPLACE FUNCTION auth_org_role() RETURNS TEXT AS $$ SELECT current_setting('request.jwt.claims', true)::json->>'org_role';$$ LANGUAGE sql STABLE;
-- Organizations: members can read their orgsCREATE POLICY orgs_member_read ON organizations FOR SELECT USING ( id IN (SELECT org_id FROM org_members WHERE user_id = auth_user_id()) );
-- Org members: see members of your orgsCREATE POLICY members_same_org ON org_members FOR SELECT USING (org_id = auth_org_id());
-- Admins can manage membersCREATE POLICY members_admin_write ON org_members FOR INSERT WITH CHECK ( auth_org_role() IN ('owner', 'admin') AND org_id = auth_org_id() );
CREATE POLICY members_admin_delete ON org_members FOR DELETE USING ( auth_org_role() IN ('owner', 'admin') AND org_id = auth_org_id() AND user_id != auth_user_id() -- can't remove yourself );
-- Projects: full CRUD within orgCREATE POLICY projects_org_isolation ON projects FOR ALL USING (org_id = auth_org_id()) WITH CHECK (org_id = auth_org_id());
-- Invitations: admins onlyCREATE POLICY invitations_admin ON org_invitations FOR ALL USING ( org_id = auth_org_id() AND auth_org_role() IN ('owner', 'admin') );3. Org-aware JWT claims
Section titled “3. Org-aware JWT claims”After login, the user selects an organization. Exchange the session for an org-scoped token:
import { createClient } from "@reactor/client";
const reactor = createClient({ url: "https://api.myapp.com" });
export async function switchOrganization(orgId: string) { const { session } = await reactor.auth.refreshSession({ org_id: orgId, });
// Session JWT now includes: // { sub: "user-uuid", org_id: "org-uuid", org_role: "admin" }
localStorage.setItem("reactor_session", JSON.stringify(session)); return session;}The auth service resolves org membership and embeds claims:
// What the JWT payload looks like after org selection{ "sub": "019213f5-0000-7000-8000-000000000001", "email": "user@example.com", "org_id": "019213f5-0000-7000-8000-000000000002", "org_role": "admin", "iss": "reactor-auth", "aud": "reactor", "exp": 1716970800}4. Create organization flow
Section titled “4. Create organization flow”export async function createOrganization(name: string, slug: string) { // Create org (uses base JWT — no org_id yet) const { data: org } = await reactor.data .from("organizations") .insert({ name, slug }) .select() .single();
// Add creator as owner (via function with service role) await reactor.functions.invoke("setup-org", { org_id: org.id, user_id: (await reactor.auth.getUser()).id, role: "owner", });
// Switch to new org context await switchOrganization(org.id); return org;}Setup function (service role — bypasses RLS for bootstrap):
export default async function handler(req: Request) { const { org_id, user_id, role } = await req.json(); const reactor = ReactorClient.service();
await reactor.data.from("org_members").insert({ org_id, user_id, role, });
// Create default storage prefix await reactor.storage.from("org-assets").upload( `${org_id}/.keep`, new Uint8Array(0), { contentType: "application/octet-stream" } );
return Response.json({ ok: true });}5. Invitation flow
Section titled “5. Invitation flow”// Admin invites a teammateexport async function inviteMember(email: string, role: "admin" | "member") { const { data: invitation } = await reactor.data .from("org_invitations") .insert({ email, role }) .select("id, token") .single();
// Send email via job or function await reactor.jobs.trigger("send-invitation-email", { email, token: invitation.token, org_name: currentOrg.name, });
return invitation;}
// Invitee acceptsexport async function acceptInvitation(token: string) { const result = await reactor.functions.invoke("accept-invitation", { token }); await switchOrganization(result.org_id);}Accept handler:
export default async function handler(req: Request) { const { token } = await req.json(); const user = await getAuthenticatedUser(req); // from JWT const reactor = ReactorClient.service();
const { data: invite } = await reactor.data .from("org_invitations") .select("*") .eq("token", token) .is("accepted_at", null) .gt("expires_at", new Date().toISOString()) .single();
if (!invite) return Response.json({ error: "Invalid invitation" }, { status: 404 }); if (invite.email !== user.email) { return Response.json({ error: "Wrong email" }, { status: 403 }); }
await reactor.data.from("org_members").insert({ org_id: invite.org_id, user_id: user.id, role: invite.role, });
await reactor.data.from("org_invitations") .update({ accepted_at: new Date().toISOString() }) .eq("id", invite.id);
return Response.json({ org_id: invite.org_id });}6. Org-scoped storage
Section titled “6. Org-scoped storage”Prefix all storage paths with org_id:
export function orgStoragePath(orgId: string, filename: string) { return `${orgId}/${crypto.randomUUID()}/${filename}`;}
// RLS on a storage_metadata table mirrors path prefix// Or enforce via function that validates org_id in pathStorage RLS via metadata table:
CREATE TABLE storage_objects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organizations(id), bucket TEXT NOT NULL, path TEXT NOT NULL, created_by UUID NOT NULL, UNIQUE (bucket, path));
ALTER TABLE storage_objects ENABLE ROW LEVEL SECURITY;
CREATE POLICY storage_org_isolation ON storage_objects FOR ALL USING (org_id = auth_org_id());7. Role-based UI
Section titled “7. Role-based UI”export function useOrgRole() { const session = useSession(); return { role: session?.org_role as "owner" | "admin" | "member", isOwner: session?.org_role === "owner", isAdmin: ["owner", "admin"].includes(session?.org_role ?? ""), canManageMembers: ["owner", "admin"].includes(session?.org_role ?? ""), canDeleteProjects: ["owner", "admin"].includes(session?.org_role ?? ""), };}
// Usagefunction ProjectActions({ project }) { const { canDeleteProjects } = useOrgRole(); return ( <div> <button>Edit</button> {canDeleteProjects && <button onClick={() => deleteProject(project.id)}>Delete</button>} </div> );}8. Org switcher component
Section titled “8. Org switcher component”export function OrgSwitcher() { const [orgs, setOrgs] = useState<Organization[]>([]); const [current, setCurrent] = useState<string | null>(null);
useEffect(() => { reactor.data .from("organizations") .select("id, name, slug, org_members(role)") .then(({ data }) => setOrgs(data ?? [])); }, [current]);
async function handleSwitch(orgId: string) { await switchOrganization(orgId); setCurrent(orgId); window.location.reload(); // refresh all org-scoped data }
return ( <select value={current ?? ""} onChange={(e) => handleSwitch(e.target.value)}> {orgs.map((org) => ( <option key={org.id} value={org.id}>{org.name}</option> ))} </select> );}9. Reactor.cloud multi-tenant (C6@fly)
Section titled “9. Reactor.cloud multi-tenant (C6@fly)”On the shared cluster, each project maps to tenant_<ref>:
[cloud]multi_tenant = trueprovider = "shared_cluster"base_domain = "reactor.cloud"
[cloud.shared_pool]shared_postgres_url = "postgres://admin@shared-pg.internal/postgres"per_tenant_pool_size = 5Each customer’s Reactor project gets isolated database, quotas, and subdomain. Your SaaS app’s org model (above) runs within each tenant’s Reactor instance — two layers of multi-tenancy:
- Platform level (Reactor.cloud):
tenant_<ref>database isolation - Application level (your schema):
org_idRLS within the tenant
Testing isolation
Section titled “Testing isolation”Verify RLS prevents cross-org access:
// Test: user in org A cannot read org B projectstest("org isolation", async () => { const sessionA = await loginAs("user@org-a.com", "org-a-id"); const clientA = createClient({ url, token: sessionA.access_token });
const { data, error } = await clientA .from("projects") .select("*") .eq("org_id", "org-b-id"); // attempt cross-org read
expect(data).toHaveLength(0); // RLS filters out all rows});Summary
Section titled “Summary”| Layer | Mechanism |
|---|---|
| Identity | JWT with org_id + org_role claims |
| Data | RLS policies on every tenant table |
| Storage | Path prefix + metadata RLS |
| Functions | Service role for bootstrap; user JWT for app logic |
| Invitations | Token-based with expiry |
Related
Section titled “Related”- Security — RLS patterns and checklist
- OAuth setup — social login for org members
- Deployment topologies — C6@fly shared cluster