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.

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:

AuthorityOwnsWhere it lives
RouteWhich tree mounts — (user) for personal, [account] plus a slug for a teamapps/native/app/(home)/(user)/, apps/native/app/(home)/[account]/
ContextIn-session "what's active" reads, from anywhere in the treeapps/native/lib/workspace/workspace-context.tsx
Secure storeCold-boot seed only: which workspace to open the app intoapps/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:

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.

HookReturnsUse it when
useActiveWorkspace()ActiveWorkspaceYou need the slug and the team's display data (name, image). The common case.
useWorkspaceSelection()PersistedWorkspaceYou need only the raw selection and want no dependency on the workspace query.
useSetActiveWorkspace()(next: PersistedWorkspace) => voidYou are changing the active workspace — switcher rows, team creation, leaving a team, a slug change.

PersistedWorkspace is the minimal, storable shape:

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

ActiveWorkspace is that selection joined against the workspace summary:

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:

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:

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:

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:

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.

HookEndpointReturnsUse it for
useUserWorkspace()GET /api/v1/me/workspaceUserWorkspaceShape: accounts (every team the user belongs to, as { label, value, image }), workspace (the personal account row), user, and canCreateTeamAccountAnything 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, userAnything 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:

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.

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.

FeatureHookScreenFlag
Create a teamuseCreateTeamAccount()apps/native/app/(home)/new-team.tsxEXPO_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION
Rename / change sluguseUpdateTeamAccount(slug).../[account]/(tabs)/settings/edit-team-name.tsx
Team logouseUpdateTeamAccount(slug) via TeamPictureControls.../[account]/(tabs)/settings/index.tsx
Leave a teamuseLeaveTeamAccount(slug).../[account]/(tabs)/settings/leave-team.tsx
Members listuseTeamMembers().../[account]/(tabs)/members/index.tsx
Invite membersuseInviteMembers(slug).../[account]/(tabs)/members/invite.tsx
Pending invitations listuseTeamInvitations().../[account]/(tabs)/members/index.tsx
Change an invitation's roleuseUpdateInvitationRole(slug).../[account]/members-edit-invitation-role.tsx
Revoke an invitationuseDeleteInvitation(slug).../[account]/(tabs)/members/index.tsx
Renew an invitationuseRenewInvitation(slug).../[account]/(tabs)/members/index.tsx
Change a member's roleuseUpdateMemberRole(slug).../[account]/members-edit-member-role.tsx
Remove a memberuseRemoveMember(slug).../[account]/(tabs)/members/index.tsx
Accept an invitation you receiveduseAcceptInvitation()apps/native/app/(home)/invitations.tsxEXPO_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:

FlagDefaultEffect
teamAccountsEnabledonOff hides the switcher trigger entirely and makes resolveWorkspaceTarget always return personal. Also disables the pending-invitations query.
teamAccountsOnlyoffThe personal tree becomes unreachable — (user)/_layout redirects away from it, and a user with no teams lands on the create-team screen.
teamAccountsCreationonGates 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.

PieceFilePurpose
useAccountRoles(slug?, enabled?)features/team-accounts/hooks/queries/use-account-roles.tsThe assignable role names for a team, most-elevated first
useRoleLabel()features/team-accounts/hooks/use-role-label.tsResolves a role string to a localised label, falling back to the raw name
RoleSelectfeatures/team-accounts/components/members/role-select.tsxThe shared picker used by invite, invitation-role edit and member-role edit
RoleBadgefeatures/team-accounts/components/members/role-badge.tsxRenders 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:

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:

const { account, userId, role: currentRole } = useLocalSearchParams<{
account: string;
userId: string;
role: string;
}>();
const roles = useAccountRoles(account);
const updateRole = useUpdateMemberRole(account ?? '');

The switcher UI

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

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.

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:

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.