Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Multi-Tenancy (Org-Scoped Queries)

OrgProvider scopes every Supabase query through user_org_ids() SECURITY DEFINER, giving each org isolated data without the RLS recursion bug (42P17).

  • Tier: Common
  • Status: Config-toggled
  • Config: multiTenancy.enabled

WHY IT MATTERS

Adding multi-tenancy to a Supabase app by hand surfaces a well-documented Postgres trap: if your RLS policy on organization_members reads FROM organization_members to check membership, Postgres evaluates the policy when scanning that same table, which triggers the policy again, producing infinite recursion and the 42P17 error. The naive fix, a self-join with a subquery, does not help because the policy fires on every row-scan regardless. The correct fix is a SECURITY DEFINER helper function that runs with the definer's privileges rather than the caller's, bypassing the policy evaluation on the table it queries. The GAS template ships this as user_org_ids(p_user uuid), a stable SQL function that returns the set of organization IDs the given user belongs to. RLS policies on organizations and organization_members reference this function in their USING clause. Postgres calls it once per query, not once per row, and the helper is never subject to the policies it supports. The 42P17 error is structurally impossible once user_org_ids() is in place. The organizations and organization_members tables, the RLS policies, and the SECURITY DEFINER function are all created in a single Supabase migration file and require no manual setup.

On the client side, a single boolean flag in gas.config.ts activates the full feature. Setting multiTenancy.enabled to true mounts OrgProvider at the root of the app tree. OrgProvider fetches the current user's organizations on mount using the explicit column list id, name, slug, owner_user_id to avoid over-fetching large metadata columns on the boot path, sets the first available org as the active context, and exposes a setCurrent(orgId) switcher for multi-org users. The orgFilter(query, currentOrgId) utility is the integration point for data screens: wrap any Supabase query builder with orgFilter and it appends an .eq('organization_id', currentOrgId) filter when multi-tenancy is enabled and an org is selected, or returns the query unchanged when the feature is off or the user has no active org. Feature screens need no conditional imports and no config checks. They call orgFilter and the correct behavior follows from the config automatically. When multiTenancy.enabled is false, orgFilter is a no-op and the entire OrgProvider tree renders its children immediately without a boot fetch, so single-user apps pay no runtime cost for the feature being present in the codebase.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from lib/multitenancy.tsx in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// lib/multitenancy.tsx: OrgProvider + orgFilter
// Toggle via gasConfig.multiTenancy.enabled in gas.config.ts.

export function OrgProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<OrgState>({
    current: null, available: [], setCurrent: () => {}, loading: true,
  });

  useEffect(() => {
    if (!gasConfig.multiTenancy.enabled) {
      setState(s => ({ ...s, loading: false }));
      return;
    }
    (async () => {
      const { data: orgs } = await supabase
        .from('organizations')
        .select('id, name, slug, owner_user_id');
      const available = (orgs ?? []) as Organization[];
      setState({
        current: available[0] ?? null,
        available,
        setCurrent: (orgId: string) => {
          const next = available.find(o => o.id === orgId) ?? null;
          setState(s => ({ ...s, current: next }));
        },
        loading: false,
      });
    })();
  }, []);

  return <OrgContext.Provider value={state}>{children}</OrgContext.Provider>;
}

// orgFilter: no-op when feature is off or no org is active.
export function orgFilter<T extends AnyFilterBuilder>(
  query: T,
  currentOrgId: string | null,
): T {
  if (!gasConfig.multiTenancy.enabled || !currentOrgId) return query;
  return query.eq('organization_id', currentOrgId) as T;
}

Source: goodspeed-apps/gas-template lib/multitenancy.tsx

HONEST LIMITS

When Multi-Tenancy (Org-Scoped Queries) is the wrong choice

Single-user apps gain nothing from org scoping. Every query that passes through orgFilter gets an extra .eq('organization_id', ...) filter appended at the Postgres layer, and OrgProvider makes a network round-trip on every cold start to load an organizations list that will always contain exactly one entry. When every row in the table already belongs to a single user and no shared-workspace concept exists in the product, that overhead is pure waste. Leave multiTenancy.enabled at false. OrgProvider will skip its fetch, orgFilter will be a no-op, and the organizations and organization_members tables in the migration will sit empty without affecting anything else. The RLS policies on those tables still apply, but since no rows exist, no queries are affected. The OrgProvider pattern also assumes that org membership is relatively stable within a session. It loads the full organizations list once on mount and holds it in React state. If your product is an admin console where the operator needs to switch between hundreds of tenant accounts, or a support tool where the acting user impersonates different organizations on every request, the single-load-on-mount pattern will produce stale data as soon as membership changes mid-session. In those contexts, resolve the active organization server-side per request using the service-role client, which bypasses RLS entirely and carries its own explicit authorization checks. The service-role bypass pattern is documented in the row-level-security feature. The OrgProvider is the right design for products where a user has a primary organization they work within, not for tools that need arbitrary cross-tenant access.

Tier: Common · Config-toggled

  1. Evaluate your use case

    Check whether multi-tenancy (org-scoped queries) aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `multiTenancy.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes multi-tenancy (org-scoped queries). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Multi-Tenancy (Org-Scoped Queries) capability breakdown

Concrete dimensions of what the built-in multi-tenancy (org-scoped queries) implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
ActivationSet gasConfig.multiTenancy.enabled = true in gas.config.ts. OrgProvider mounts automatically when the flag is on and boots with a single organizations fetch on mount. orgFilter is a no-op when the flag is off, with zero runtime overhead.Config-toggled
RLS recursion preventionuser_org_ids() is a SECURITY DEFINER SQL function granted only to authenticated and service_role. It queries organization_members with definer privileges, so the RLS policy on that table is never evaluated during the membership lookup, preventing the 42P17 infinite-recursion error.SECURITY DEFINER helper
Client org contextOrgProvider fetches the user's organizations on mount using an explicit column list (id, name, slug, owner_user_id) and exposes current, available, and setCurrent via React context. Screens read the current org via useCurrentOrg().React context
Query scopingorgFilter(query, currentOrgId) appends .eq('organization_id', currentOrgId) to any Supabase query builder when multi-tenancy is enabled and an org is active. When the feature is off or no org is selected, the original query is returned unchanged.orgFilter utility
Role modelorganization_members.role accepts owner, admin, and member. Owners get an update policy on the organization row. Members get a read-only view of their org and all org member rows via user_org_ids(). Write operations go through service_role.owner / admin / member

COMMON QUESTIONS

What exactly causes the 42P17 error and how does the SECURITY DEFINER fix it?

PostgreSQL error 42P17 means infinite recursion was detected in a policy. It happens when an RLS policy on table A contains a subquery that reads from table A again, causing Postgres to evaluate the policy while already evaluating it. A typical org-membership check on organization_members writes something like USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid())). When Postgres scans organization_members to execute that subquery, it first evaluates the RLS policy on organization_members, which runs the same subquery, which triggers the same scan, and the chain has no exit. SECURITY DEFINER is the correct escape hatch: user_org_ids() runs with the permissions of the function owner rather than the calling user, so Postgres does not apply the caller's RLS policies when the function queries organization_members internally. The recursion loop is broken at the boundary. The function is declared STABLE (no side effects, result is consistent within a transaction), REVOKE'd from public and anon, and GRANT'd only to authenticated and service_role, so unauthenticated callers cannot invoke it directly. Postgres calls the function once per query rather than once per row, which also keeps the cost bounded regardless of how many org rows exist.

How do I scope a new data table to the active organization?

Add an organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE column to the new table and enable RLS on it. Write a SELECT policy using USING (organization_id IN (SELECT public.user_org_ids(auth.uid()))). That single policy gives every member of the org read access to all rows in the org, with no cross-org visibility possible from the anon-key client. For writes, decide whether any authenticated member can insert or whether inserts go through service_role. On the client side, import orgFilter from lib/multitenancy.tsx and wrap your Supabase query with it: const { data } = await orgFilter(supabase.from('projects').select('*'), currentOrg?.id ?? null). When multiTenancy is enabled and a current org is set, orgFilter appends .eq('organization_id', currentOrgId) automatically. When the feature is off, orgFilter returns the query unchanged, so the same screen code works in single-user apps without any branching or conditional imports. The only file you need to edit for a new org-scoped table is the Supabase migration and the data screen that fetches from it.

Can a user belong to multiple organizations and switch between them?

Yes. OrgProvider loads all organizations the current user is a member of into the available array on mount. The setCurrent(orgId) function updates the current value in React context synchronously, with no network round-trip. Any component rendered under OrgProvider that calls useCurrentOrg() receives the updated current value on the next render cycle. Screens that pass currentOrg?.id to orgFilter will then filter queries by the new org on the next query execution. For screens that hold query results in local state, call the data-fetch function again after the context updates to pick up the new org's rows. The org-switcher UI component, a picker or dropdown that calls setCurrent when the user selects a different organization, is not bundled in the base template because its visual design is product-specific, but it is a few lines to wire against setCurrent. If your product supports creating new organizations mid-session, you will need to add the new org to the available array manually or re-mount OrgProvider, since the list is fetched once on mount.

Does multi-tenancy work on web and not just native?

Yes. lib/multitenancy.tsx uses only React context primitives, useState, useEffect, and the Supabase JavaScript client. None of these have native-only dependencies or platform guards. OrgProvider mounts and orgFilter operates identically on Expo web, in a Next.js app router that wraps a client component boundary, or in any React environment that can render a context provider. The Supabase queries that OrgProvider issues use the same anon-key client used throughout the rest of the template. RLS policies enforce organization scoping at the Postgres layer regardless of what client platform issued the query, so a web user cannot read another org's rows even if they construct the HTTP request manually and supply their own JWT. The only platform-specific consideration in the broader multi-tenancy stack is session persistence via SecureStore on native and localStorage on web, which is handled upstream by the supabase-auth feature and is entirely separate from multi-tenancy.

GET IT BUILT INTO YOUR APP

Score your idea and get multi-tenancy (org-scoped queries) wired in from day one