# Workspaces and team accounts in the Expo app | Expo Supabase SaaS Kit

> Read the active workspace from any screen, understand how the route, context and secure store each own part of the answer, and use the team-account hooks that ship.

*Canonical: https://makerkit.dev/docs/react-native-supabase/workspaces*

---

Every screen in the native app runs inside a workspace: either the signed-in user's personal account or one of their team accounts. This page covers how the app decides which one is active, the hooks you call to read it, and the team-account features that ship on top.

## The account model, briefly

Makerkit is multi-tenant at the database level, and the native app inherits that model unchanged. A **personal account** is a row in `accounts` whose `id` equals the `auth.users.id` of its owner. A **team account** is a row with its own UUID, a `slug`, and a set of members joined through `accounts_memberships`, each holding a role that maps to permissions. Your own tables link to a workspace through an `account_id` foreign key, and RLS enforces who may read them. Nothing about that changes for native — the app is a client of the same database, reached through `apps/web`'s `/api/v1/*` endpoints. For the database side, see the web documentation on database architecture and on roles and permissions.

## The three authorities

The hardest part of a native workspace model is that "what is active" is asked in three different situations, and no single store answers all three. The kit therefore gives each concern exactly one authority. The header comment at the top of `apps/native/lib/workspace/workspace-context.tsx` states the split:

| Authority | Owns | Where it lives |
| --- | --- | --- |
| **Route** | Which tree mounts — `(user)` for personal, `[account]` plus a slug for a team | `apps/native/app/(home)/(user)/`, `apps/native/app/(home)/[account]/` |
| **Context** | In-session "what's active" reads, from anywhere in the tree | `apps/native/lib/workspace/workspace-context.tsx` |
| **Secure store** | Cold-boot seed only: which workspace to open the app into | `apps/native/lib/workspace/workspace-storage.ts` |

Each one exists because the other two cannot cover its case.

**The route cannot be the only source.** The workspace switcher is registered on the `(home)` Stack as a `formSheet`, one level *above* both trees (see `apps/native/app/(home)/_layout.tsx`). While the sheet is presented, the active route is `workspace-switcher` — it carries no `account` param, and a component inside it cannot read the params of the tree underneath. The same applies to `user-menu` and `new-team`. Those screens still need to render a checkmark next to the current workspace, so they read context, not route.

**Context cannot be the only source.** It is React state: it starts at `{ kind: 'personal' }` on every launch and knows nothing about where the user was last time. It is also downstream of navigation — a deep link straight into `/[account]` must decide the tree before any provider has an opinion.

**The secure store cannot be the only source.** `expo-secure-store` is asynchronous, so it cannot answer a synchronous render, and a persisted preference is only a preference: the user may have been removed from that team since the last launch. So the stored value is read exactly once, at `apps/native/app/(home)/index.tsx`, and passed to `resolveWorkspaceTarget` in `apps/native/lib/workspace/navigation.ts`, which honours it only when the slug is still a current membership.

The trees keep context in step with the route. `apps/native/app/(home)/[account]/_layout.tsx` confirms membership against the workspace query first, and only then marks the team active:

```tsx
const isMember = workspace.data?.accounts.some((a) => a.value === slug);

// Mark the team active only after membership is confirmed.
useEffect(() => {
  if (isMember && slug) {
    setActive({ kind: 'team', slug });
  }
}, [isMember, slug, setActive]);
```

`apps/native/app/(home)/(user)/_layout.tsx` does the mirror image for personal. Route guards and redirects are covered in `docs/native/routing-navigation.mdoc`.

## The hooks you will actually use

All three are exported from `apps/native/lib/workspace/workspace-context.tsx` and all three throw if called outside `<WorkspaceProvider>`, which `apps/native/app/(home)/_layout.tsx` mounts around the whole signed-in shell.

| Hook | Returns | Use it when |
| --- | --- | --- |
| `useActiveWorkspace()` | `ActiveWorkspace` | You need the slug **and** the team's display data (name, image). The common case. |
| `useWorkspaceSelection()` | `PersistedWorkspace` | You need only the raw selection and want no dependency on the workspace query. |
| `useSetActiveWorkspace()` | `(next: PersistedWorkspace) => void` | You are changing the active workspace — switcher rows, team creation, leaving a team, a slug change. |

`PersistedWorkspace` is the minimal, storable shape:

```ts
export type PersistedWorkspace =
  | { kind: 'personal' }
  | { kind: 'team'; slug: string };
```

`ActiveWorkspace` is that selection joined against the workspace summary:

```ts
export type ActiveWorkspace =
  | { kind: 'personal'; slug?: undefined; account: null }
  | { kind: 'team'; slug: string; account: UserAccountSummary | null };
```

The union is discriminated on `kind`, so narrowing on `active.kind === 'team'` gives you a `string` slug with no non-null assertion. That is the pattern the query hooks use:

```ts
const active = useActiveWorkspace();
const slug = active.kind === 'team' ? active.slug : undefined;
```

### `account` may be null while a team is active

This is the subtlety worth internalising. `useActiveWorkspace` resolves the team summary by looking the slug up in the `/me/workspace` response:

```ts
const account =
  workspace.data?.accounts.find((a) => a.value === selection.slug) ?? null;
```

So `{ kind: 'team', account: null }` is a real, expected state: **the team is active, but its summary has not arrived yet.** It happens on a cold start into a team, and again after a cache invalidation. Do not read it as "no team" and do not fall back to rendering the slug or a `?` placeholder — that produces a visible flash of the wrong label. Render a placeholder for the pending case, as `apps/native/components/home-header.tsx` does:

```tsx
const isTeam = active.kind === 'team';

// `account === null` for an active team means its summary is still loading.
const isLoadingTeam = isTeam && active.account === null;

return isLoadingTeam ? (
  <Skeleton className="size-7 rounded-full" />
) : (
  <WorkspaceAvatar kind={active.kind} label={label} imageUri={imageUri} />
);
```

### Why `setActive` is shaped the way it is

`setActive` is wrapped in `useCallback` with an empty dependency list, and the comment in the provider explains why: its identity is load-bearing. Both `(user)/_layout` and `[account]/_layout` call it from inside a `useEffect` and list it as a dependency, so a new function identity on every render would re-run those effects on every render. React Compiler does not stabilise it in all cases, so the memoisation is explicit rather than inferred.

Two more properties follow from the same effects:

- **Idempotent.** The updater compares the incoming selection with the previous one and returns `prev` unchanged when they match. A no-op selection must not call `setState` and must not re-write the Keychain — otherwise the layout effects above would write on every render pass.
- **Fire-and-forget.** `saveActiveWorkspace(next)` is called with `void` and never awaited. Navigation must never gate on Keychain I/O; persistence failures are logged in development and swallowed, and a failed write costs the user nothing worse than opening into the wrong workspace next launch.

That is also why callers set the selection *before* navigating rather than after. `apps/native/app/(home)/workspace-switcher.tsx` sequences it explicitly:

```tsx
function selectWorkspace(
  setActive: (next: PersistedWorkspace) => void,
  target: PersistedWorkspace,
  href: Href,
) {
  setActive(target);
  router.dismiss();
  router.replace(href);
}
```

Because `setActive` is synchronous, the frame that renders the new tree already has the right selection — there is no stale-frame gap where the header shows the old workspace.

## The data behind it

Two queries back the whole model. Both go through the `api()` client; the request mechanics, error handling and React Query defaults are covered in `docs/native/data-fetching.mdoc`.

| Hook | Endpoint | Returns | Use it for |
| --- | --- | --- | --- |
| `useUserWorkspace()` | `GET /api/v1/me/workspace` | `UserWorkspaceShape`: `accounts` (every team the user belongs to, as `{ label, value, image }`), `workspace` (the personal account row), `user`, and `canCreateTeamAccount` | Anything that spans workspaces: the switcher, membership checks, the create-team policy gate |
| `useTeamWorkspace()` | `GET /api/v1/accounts/teams/[slug]` | `TeamAccountWorkspaceShape`: `account` (the team plus the caller's `role`, `permissions`, `role_hierarchy_level`, `primary_owner_user_id`, `subscription_status`), `accounts`, `user` | Anything about the *current* team: settings, permission gating, the members screen |

`useUserWorkspace` lives at `apps/native/features/accounts/hooks/queries/use-user-workspace.ts` and is keyed on the user id; `useTeamWorkspace` lives at `apps/native/features/team-accounts/hooks/queries/use-team-workspace.ts`, derives its slug from `useActiveWorkspace()`, and is enabled only while a team is active. Both mirror web's `loadUserWorkspace` / `loadTeamWorkspace` by calling the same server-side composers, so a rule you add on web applies to native without a second implementation.

The same file exports `useTeamPermissions()`, which flattens `account.permissions` into typed booleans:

```ts
const { canManageMembers, canManageInvites, canManageRoles } = useTeamPermissions();
```

Every flag is `false` while the query is pending, so gate *actions* on them, not reads — otherwise a member briefly sees a screen with no content.

{% alert type="warning" title="Permission booleans are a UI affordance, not the enforcement" %}
`useTeamPermissions` decides what to render. Authorization is enforced by RLS and by the `/api/v1` route handlers on the server. Never treat a client-side check as the security boundary.
{% /alert %}

## Team features that ship

Every mutation below lives under `apps/native/features/team-accounts/hooks/` and invalidates the query keys its change affects, so the switcher and the members list stay correct without manual refetching.

{% img src="/images/docs/native-team-members.webp" width="402" height="874" alt="The native Members tab: an Invite Members button, a member search field, and member rows badged Owner, Member and Custom-Role, with Pending Invites below" /%}

The Members tab is the densest of these surfaces, and it shows two of the client-side rules the rest of this page describes. Role badges come from `RoleBadge` — `owner` and `member` get distinct tones, every custom role shares one — and the per-row overflow menu only renders when `canActOnMember` returns true: never on your own row, never on the primary owner's, and otherwise only if you are the primary owner or outrank the member. Rows are sorted primary owner first, then by ascending hierarchy level, matching web.

| Feature | Hook | Screen | Flag |
| --- | --- | --- | --- |
| Create a team | `useCreateTeamAccount()` | `apps/native/app/(home)/new-team.tsx` | `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION` |
| Rename / change slug | `useUpdateTeamAccount(slug)` | `.../[account]/(tabs)/settings/edit-team-name.tsx` | — |
| Team logo | `useUpdateTeamAccount(slug)` via `TeamPictureControls` | `.../[account]/(tabs)/settings/index.tsx` | — |
| Leave a team | `useLeaveTeamAccount(slug)` | `.../[account]/(tabs)/settings/leave-team.tsx` | — |
| Members list | `useTeamMembers()` | `.../[account]/(tabs)/members/index.tsx` | — |
| Invite members | `useInviteMembers(slug)` | `.../[account]/(tabs)/members/invite.tsx` | — |
| Pending invitations list | `useTeamInvitations()` | `.../[account]/(tabs)/members/index.tsx` | — |
| Change an invitation's role | `useUpdateInvitationRole(slug)` | `.../[account]/members-edit-invitation-role.tsx` | — |
| Revoke an invitation | `useDeleteInvitation(slug)` | `.../[account]/(tabs)/members/index.tsx` | — |
| Renew an invitation | `useRenewInvitation(slug)` | `.../[account]/(tabs)/members/index.tsx` | — |
| Change a member's role | `useUpdateMemberRole(slug)` | `.../[account]/members-edit-member-role.tsx` | — |
| Remove a member | `useRemoveMember(slug)` | `.../[account]/(tabs)/members/index.tsx` | — |
| Accept an invitation you received | `useAcceptInvitation()` | `apps/native/app/(home)/invitations.tsx` | `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS` |

The three invitation mutations are exported together from `hooks/mutations/use-manage-invitations.ts`; the two member mutations from `hooks/mutations/use-manage-members.ts`.

### What the flags do

`apps/native/features/core/config/feature-flags.config.ts` reads five booleans from `EXPO_PUBLIC_*` variables, mirroring web's `getBoolean` helper. Three of them shape the workspace model:

| Flag | Default | Effect |
| --- | --- | --- |
| `teamAccountsEnabled` | on | Off hides the switcher trigger entirely and makes `resolveWorkspaceTarget` always return personal. Also disables the pending-invitations query. |
| `teamAccountsOnly` | off | The personal tree becomes unreachable — `(user)/_layout` redirects away from it, and a user with no teams lands on the create-team screen. |
| `teamAccountsCreation` | on | Gates the "Create team" row in the switcher, on top of the server-side `canCreateTeamAccount` policy result. |

`teamAccountsDeletion` gates a "manage on web" notice rather than a native flow, and `personalAccountDeletion` is unrelated to workspaces. The full configuration surface, and what changing one setting forces elsewhere, is in `docs/native/config-requirements.mdoc`.

Rename and logo changes are shown only to the primary owner, matching the server: the `PATCH /api/v1/accounts/teams/[slug]` handler relies on the request-scoped RLS client, and the `accounts_self_update` policy permits the write for the primary owner only — a member with `settings.manage` still gets zero rows back. Leaving is offered to everyone *except* the primary owner, and the server refuses it with an `owner-cannot-leave` code that the screen surfaces as a toast.

## Roles and permissions

Roles are runtime values, not a fixed enum: you can add rows to the `roles` table with your own hierarchy levels, and the app must handle them without a code change.

| Piece | File | Purpose |
| --- | --- | --- |
| `useAccountRoles(slug?, enabled?)` | `features/team-accounts/hooks/queries/use-account-roles.ts` | The assignable role names for a team, most-elevated first |
| `useRoleLabel()` | `features/team-accounts/hooks/use-role-label.ts` | Resolves a role string to a localised label, falling back to the raw name |
| `RoleSelect` | `features/team-accounts/components/members/role-select.tsx` | The shared picker used by invite, invitation-role edit and member-role edit |
| `RoleBadge` | `features/team-accounts/components/members/role-badge.tsx` | Renders a role as a badge; `owner` and `member` get distinct tones, every custom role shares one |

The important property is where the filtering happens. `GET /api/v1/accounts/teams/[slug]/roles` in `apps/web/app/api/v1/accounts/teams/[slug]/roles/route.ts` first resolves the caller's own hierarchy level through `composeTeamWorkspace`, then queries:

```ts
const { data, error } = await client
  .from('roles')
  .select('name')
  .gte('hierarchy_level', userHierarchyLevel)
  .order('hierarchy_level', { ascending: true });
```

Lower numbers are more elevated, so `gte` returns the caller's own level and everything below it. **The app never receives the names of roles it may not assign.** That is an authorization property, not a presentation convenience: a client that filtered a full role list would leak the role taxonomy and would put the only barrier in code an attacker controls. `RoleSelect` renders whatever the endpoint returned, so it cannot offer an over-privileged option even if the screen has a bug.

`useAccountRoles` defaults its slug to the active workspace, but takes an explicit one. Pass it whenever the screen operates on a specific team — the role-edit modals do, keyed on the same route param the mutation targets, so the role list can never belong to a different team than the write:

```tsx
const { account, userId, role: currentRole } = useLocalSearchParams<{
  account: string;
  userId: string;
  role: string;
}>();

const roles = useAccountRoles(account);
const updateRole = useUpdateMemberRole(account ?? '');
```

## The switcher UI

| File | What it is |
| --- | --- |
| `apps/native/components/home-header.tsx` | The pill in the header that opens the switcher, plus the pending-invitation dot |
| `apps/native/app/(home)/workspace-switcher.tsx` | The `formSheet` itself: personal row, team rows, create-team row, invitations row |
| `apps/native/app/(home)/new-team.tsx` | The create-team modal |
| `apps/native/features/makerkit/components/workspace-avatar.tsx` | The avatar used by every row, the header pill and team settings |

{% img src="/images/docs/native-create-team.webp" width="402" height="874" alt="The Create Team form sheet: a Team Name field with a hint, a Create Team button and a Cancel button" /%}

`new-team.tsx` is presented as a `formSheet` on the `(home)` Stack rather than pushed into a tree, which is why the switcher — itself one level above both trees — can open it. The slug field is deliberately absent above: the server derives the slug from the name, and the field only appears if the name contains non-Latin characters or the server rejects a derived slug as a duplicate. On success the screen eager-switches the active workspace, dismisses the sheet and `replace`s to the new team, while `useCreateTeamAccount()` invalidates `['user-workspace']` so the switcher picks the team up.

The sheet reads `useUserWorkspace()` for the team list, `useActiveWorkspace()` for the checkmark, and `useMyInvitations()` for the pending count. The personal row deliberately shows the generic user icon rather than the personal account's picture, matching web.

`WorkspaceAvatar` passes its `imageUri` through `resolveImageUri` from `apps/native/features/core/dev-host.ts` before handing it to `<Image>`. Storage image URLs bypass the Supabase client and hit the network directly, so on an Android emulator in development they need the same loopback rewrite the API client applies — `docs/native/running-locally.mdoc` explains the rewrite and when to opt out of it. If you build your own avatar component, apply `resolveImageUri` at the `<Image source={{ uri }}>` boundary or your images will silently fail to load on the Android emulator.

{% alert type="info" title="SVG account images fall back to initials" %}
React Native's `<Image>` cannot decode `image/svg+xml`, and the `account_image` bucket may hold either raster or SVG. An SVG source fails to load and falls through to the initial-letter fallback. Web renders it correctly through `<img>`, so this is a native-only difference.
{% /alert %}

## Adding workspace-scoped data of your own

A hook that reads team-scoped data should derive its slug from the active workspace and stay disabled until there is one. That is the pattern in `apps/native/features/team-accounts/hooks/queries/use-team-members.ts`, and it is the one to copy:

```ts
import { useQuery } from '@tanstack/react-query';

import { api } from '~/features/core/api-client';
import { useActiveWorkspace } from '~/lib/workspace/workspace-context';

export function useProjects() {
  const active = useActiveWorkspace();
  const slug = active.kind === 'team' ? active.slug : undefined;

  return useQuery({
    queryKey: ['projects', slug ?? ''],
    queryFn: () => api<Project[]>('/accounts/teams/' + slug + '/projects'),
    enabled: !!slug,
    staleTime: 30_000,
  });
}
```

Three things make this correct. The slug comes from context, so the hook works from a modal presented above the team tree as well as from inside it. `enabled: !!slug` means the query never fires with `undefined` in the path while the user is on a personal screen. And the slug is part of the query key, so switching teams does not serve one team's data to another.

`useActiveWorkspace` is one of the two sanctioned exceptions to the app's import rule — files under `features/` may import it from `~/lib/workspace/workspace-context` even though it lives at the app layer. See `docs/native/project-structure.mdoc`. The server side of the endpoint, and how to add one, is in `docs/native/data-fetching.mdoc`.

## Not built

Two team-account features from web have no native flow:

- **Delete a team.** Primary owners see a "manage on web" notice when `EXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION` is on.
- **Transfer ownership.** The primary owner sees a disabled row in the member actions menu, mirroring web's visibility.

`docs/native/covered-features.mdoc` is the authority on feature status and carries the full matrix.
