TrademarkTrademark
Ctrl k
Search docs...
Slices

Organizations

Tenants, roles, invitations and the guards that keep one tenant's rows out of another's.
The orgs slice makes an app multi-tenant: every user belongs to at least one organization, and org-scoped pages live under /app/[org]. It requires auth and adds no dependency of its own. The dashboard surface requires it; name it only for a composition without the dashboard: Run supabase/orgs.sql after init.sql. It creates organizations, organization_members and organization_invitations.
/app                  resolves the caller's default org and redirects
/app/acme             the org's home
/app/acme/settings    about the tenant
/profile              about the person, deliberately outside /app
The tenant sits under /app/ rather than at the root because a composed app shares its root with other surfaces: an organization slugged store would be shadowed by the store surface. Slugs are lowercase letters, digits and hyphens, and never change once created. /app creates the caller's personal organization on the first authenticated request that needs one, never by a trigger, then redirects. Every user always has one, so no row is ever tenant-less. role is a column on organization_members: owner, admin or member. An organization always keeps at least one owner, only an owner can make someone an owner or act on another owner, and ownership changes hands in two steps (promote, then step down). A personal organization cannot be left, deleted or invited into. RLS is enabled with no policies and the server acts through the service-role client, so nothing in the database scopes a row to a tenant. The guards in src/lib/orgs/server.ts are the only boundary. They return a result rather than throwing:
  • requireOrg(slug) — any member.
  • requireOrgRole(slug, role) — a member with at least role.
  • requireOrgAdmin(slug) — owner or admin.
  • requireOrgOwner(slug) — owner.
  • requireOrgResource(table, id, options) — a member of whichever org owns that row.
Filter org-scoped queries on guard.org.id, never on an id the request carried. The helpers in server.ts take the guard result itself, so there is no signature that accepts an unchecked id. requireOrgResource looks up the owning organization from the child row, so holding a bookingId is not a way around the check. Its table and column names are interpolated, so pass literals only. A non-member gets 404 Organization not found, the same answer as a slug that does not exist, so slugs cannot be enumerated. A member with too low a role gets 403. With no Supabase env the guards answer 503, and the [org] layout renders sample data for any slug. On the client, useOrg() from OrgProvider returns organizations, selected, slug, select, loading, error and refresh. The URL selects the organization; select(slug) keeps you on the same page under another tenant.
  • GET/POST /api/orgs — signed in: list (creating the personal org) or create.
  • GET/PATCH/DELETE /api/orgs/[org] — requireOrg / requireOrgAdmin / requireOrgOwner.
  • GET /api/orgs/[org]/members — requireOrg, one server-side page.
  • PATCH/DELETE /api/orgs/[org]/members — requireOrgAdmin; DELETE ?userId=me leaves.
  • GET/POST/DELETE /api/orgs/[org]/invitations — requireOrgAdmin.
  • GET/POST /api/invitations/[token] — signed in: preview and accept.
An admin invites an email address as admin or member. The response carries /invite/<token> once; the database keeps only its SHA-256. Stack sends no email: copy the link, or wire createInvitation to a mailer. Accepting needs a live invitation (unused, unrevoked, under 7 days old) and a signed-in user whose confirmed email matches. Signed-out visitors go to /auth?next=/invite/<token> and come back. Re-inviting an address revokes its open invitation. With the billing slice, creating one also needs the invitations plan feature. src/lib/data pages org-scoped lists on the server. The route parses the request against a spec naming what may sort and filter: useServerTable(source, spec) drives Once UI's DataTable in server mode with its state in the URL, and queryInMemory answers the same queries in demo mode. The Users page is the worked example. On a Supabase project other apps share, prefix both organization tables and adjust src/lib/orgs/ to match, as with auth.
Last updated October 18, 2026
Edit this page on GitHub