Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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_id for RLS
  • Org-scoped CRUD with invitation flow
  • Per-org storage prefixes

Prerequisites: Running Reactor server, frontend app, understanding of RLS.


-- migrations/010_multi_tenant.sql
-- Organizations
CREATE 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 roles
CREATE 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)
);
-- Invitations
CREATE 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 tables
ALTER 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;

Policies reference JWT claims set by Auth after org selection:

-- Helper: extract claims from JWT
CREATE 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 orgs
CREATE 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 orgs
CREATE POLICY members_same_org ON org_members
FOR SELECT
USING (org_id = auth_org_id());
-- Admins can manage members
CREATE 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 org
CREATE POLICY projects_org_isolation ON projects
FOR ALL
USING (org_id = auth_org_id())
WITH CHECK (org_id = auth_org_id());
-- Invitations: admins only
CREATE POLICY invitations_admin ON org_invitations
FOR ALL
USING (
org_id = auth_org_id()
AND auth_org_role() IN ('owner', 'admin')
);

After login, the user selects an organization. Exchange the session for an org-scoped token:

src/lib/org-session.ts
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
}

src/lib/organizations.ts
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):

functions/setup-org/index.ts
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 });
}

// Admin invites a teammate
export 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 accepts
export async function acceptInvitation(token: string) {
const result = await reactor.functions.invoke("accept-invitation", { token });
await switchOrganization(result.org_id);
}

Accept handler:

functions/accept-invitation/index.ts
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 });
}

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 path

Storage 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());

src/hooks/useOrgRole.ts
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 ?? ""),
};
}
// Usage
function ProjectActions({ project }) {
const { canDeleteProjects } = useOrgRole();
return (
<div>
<button>Edit</button>
{canDeleteProjects && <button onClick={() => deleteProject(project.id)}>Delete</button>}
</div>
);
}

src/components/OrgSwitcher.tsx
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>
);
}

On the shared cluster, each project maps to tenant_<ref>:

[cloud]
multi_tenant = true
provider = "shared_cluster"
base_domain = "reactor.cloud"
[cloud.shared_pool]
shared_postgres_url = "postgres://admin@shared-pg.internal/postgres"
per_tenant_pool_size = 5

Each 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:

  1. Platform level (Reactor.cloud): tenant_<ref> database isolation
  2. Application level (your schema): org_id RLS within the tenant

Verify RLS prevents cross-org access:

// Test: user in org A cannot read org B projects
test("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
});

LayerMechanism
IdentityJWT with org_id + org_role claims
DataRLS policies on every tenant table
StoragePath prefix + metadata RLS
FunctionsService role for bootstrap; user JWT for app logic
InvitationsToken-based with expiry