# Routing and navigation in the Expo app | Expo Supabase SaaS Kit

> Understand the expo-router tree in apps/native — route groups, the auth and home guards, native tabs, modal presentation — and add your own screens and tabs to it.

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

---

The Expo app routes with [expo-router](https://docs.expo.dev/router/introduction/): every file under `apps/native/app/` is a route, and every `_layout.tsx` wraps the routes beneath it. This page maps the tree the kit ships, explains the guards that decide which branch a user lands in, and ends with two walkthroughs — adding a screen, and adding a tab. Read it before you add anything to `app/`.

## The route tree

```
apps/native/app/
├── _layout.tsx                     root: providers, theme, Stack, Toaster
├── update-password.tsx             top-level, outside both guarded groups
│
├── (auth)/                         guard: no session (or a half-finished one)
│   ├── _layout.tsx
│   ├── sign-in.tsx
│   ├── sign-up.tsx
│   ├── reset-password.tsx
│   ├── verify-reset-code.tsx
│   └── mfa-challenge.tsx
│
└── (home)/                         guard: full session; adds WorkspaceProvider
    ├── _layout.tsx
    ├── index.tsx                   landing route — resolves and redirects
    ├── user-menu.tsx               modal
    ├── theme.tsx                   form sheet
    ├── workspace-switcher.tsx      form sheet
    ├── new-team.tsx                modal
    ├── invitations.tsx             pushed screen, deliberately not a modal
    │
    ├── (user)/                     personal account tree
    │   ├── _layout.tsx
    │   └── (tabs)/
    │       ├── _layout.tsx         NativeTabs: Home, Settings
    │       ├── (home)/
    │       │   ├── _layout.tsx
    │       │   └── index.tsx
    │       └── settings/
    │           ├── _layout.tsx
    │           ├── index.tsx
    │           ├── change-password.tsx
    │           ├── mfa.tsx
    │           ├── mfa-enroll.tsx
    │           └── delete-account.tsx
    │
    └── [account]/                  team account tree, keyed by slug
        ├── _layout.tsx
        ├── members-edit-invitation-role.tsx    transparent modal
        ├── members-edit-member-role.tsx        transparent modal
        └── (tabs)/
            ├── _layout.tsx         NativeTabs: Home, Settings, Members
            ├── (home)/
            │   ├── _layout.tsx
            │   └── index.tsx
            ├── settings/
            │   ├── _layout.tsx
            │   ├── index.tsx
            │   ├── edit-team-name.tsx
            │   └── leave-team.tsx
            └── members/
                ├── _layout.tsx
                ├── index.tsx
                └── invite.tsx
```

### Segment conventions

Three directory-name conventions do all the structural work. If you are new to expo-router, this table is the part to internalise — the difference between them is why `app/(home)/(user)/(tabs)/settings/mfa.tsx` is reachable at the URL `/settings/mfa`.

| Written as | Called | Appears in the URL? | What it does |
| --- | --- | --- | --- |
| `settings/` | Plain segment | Yes | A literal path segment. `settings/index.tsx` is `/settings`. |
| `(user)/` | Route **group** | No | Organisational only. Lets you attach a `_layout.tsx` — and therefore a guard, a provider, or a navigator — to a set of routes without adding a path segment. |
| `[account]/` | **Dynamic** segment | Yes, as a value | Matches any single segment and exposes it as a param. `useLocalSearchParams<{ account?: string }>()` reads it. |

Because groups are invisible to URLs, the personal settings screen navigates with `router.push('/settings/mfa')`, not the full file path. Team routes need the slug, so they push an object instead:

```tsx
router.push({
  pathname: '/(home)/[account]/(tabs)/members/invite',
  params: { account: slug },
});
```

Both forms are used in the kit. Prefer the object form whenever a dynamic segment is involved — it keeps the slug out of string concatenation.

`index.tsx` is the route for its own directory. Note that a group can also be named `(home)`: `app/(home)/(user)/(tabs)/(home)/index.tsx` is the personal home *tab*, and it is a group so the tab's URL stays `/` rather than `/home`.

## The root layout

`apps/native/app/_layout.tsx` is the only place providers are mounted. Reading outward from the screen, the stack is: `AuthProvider` → `ReactQueryProvider` → `ThemePreferenceProvider` → `I18nProvider` → `SafeAreaProvider` → `KeyboardProvider` → `GestureHandlerRootView`.

Two details matter when you touch it:

- The first frame is gated on `useThemePreference().isReady`. Until the stored preference hydrates, the layout renders a black view rather than the navigator, so the app never flashes the wrong theme. Anything you add that needs to paint before hydration has to go above that check.
- The navigator itself is `<Stack screenOptions={{ headerShown: false }} />` with no per-screen entries. Headers are opted into further down the tree, never inherited from here. `PortalHost` and `Toaster` sit *outside* the navigator, which is why a toast survives the redirect that unmounts the screen which fired it.

`global.css` is imported at the top of this file, and the `Intl.PluralRules` polyfill imports must stay above every other import — iOS Hermes ships without that API. See `docs/native/styling-theming.mdoc` and `docs/native/i18n.mdoc`.

## The guards

Two layouts decide where a user is allowed to be. Both read `useAuth()`, which is backed by `apps/native/features/auth/hooks/use-auth-state.ts`. That hook exposes a four-value status plus one orthogonal flag:

| Value | Meaning |
| --- | --- |
| `status: 'loading'` | The initial `getSession()` and the recovery-marker read have not resolved. No routing decision is safe yet. |
| `status: 'unauthenticated'` | No session. |
| `status: 'needs-mfa'` | There is a session, but `checkRequiresMfa()` says the user has an enrolled factor and has not reached `aal2`. |
| `status: 'authenticated'` | Full session, MFA satisfied (or not enrolled). |
| `isRecovering: true` | A **mode**, not a status. Set by the `PASSWORD_RECOVERY` auth event and persisted in a recovery marker, cleared on `USER_UPDATED` or `SIGNED_OUT`. It can be true alongside *any* of the statuses above. |

The full state machine — the marker, the `primed` ref that stops a cold start from wiping it, the cache clear on user switch — is owned by `docs/native/authentication.mdoc`. What you need here is that `isRecovering` is independent of `status`, because that independence is what forces the branch ordering below.

### `app/(home)/_layout.tsx`

```tsx
if (status === 'loading') return <ActivityIndicator />;

// Must stay first: a non-MFA recovery session is a full session, so only this
// branch keeps it out of the app shell.
if (isRecovering) return <Redirect href={ROUTES.updatePassword} />;

if (status === 'unauthenticated') return <Redirect href={ROUTES.signIn} />;
if (status === 'needs-mfa') return <Redirect href={ROUTES.mfaChallenge} />;
```

The ordering constraint is load-bearing and the comment in the file says so. A user who verified a recovery code and has no MFA factor reaches `status === 'authenticated'` with `isRecovering === true`. If the `authenticated` case were evaluated first, that user would enter the app shell with a half-finished password reset behind them. Putting `isRecovering` ahead of every status check is the only thing preventing it.

Past the guard, the layout mounts `WorkspaceProvider` and declares the group's `Stack` — including the modal presentations covered below. Nothing under `(home)` renders until a full, non-recovering session exists, so screens in that subtree may assume a session.

### `app/(auth)/_layout.tsx`

```tsx
if (status === 'loading') return <ActivityIndicator />;

// Resolved before the recovery branch so an `isRecovering` flag without a
// session could never loop with the update-password screen's own session check.
if (status === 'unauthenticated') {
  return isPublicAuthRoute(pathname) ? stack : <Redirect href={ROUTES.signIn} />;
}

// Past this point the user has a session.
if (isRecovering) {
  return isRecoveryPermittedRoute(pathname)
    ? stack
    : <Redirect href={ROUTES.updatePassword} />;
}

if (status === 'authenticated') return <Redirect href={ROUTES.home} />;
if (status === 'needs-mfa' && pathname !== ROUTES.mfaChallenge) {
  return <Redirect href={ROUTES.mfaChallenge} />;
}
```

Here the ordering is the mirror image, and for a different reason. `update-password` performs its own `if (!session) return <Redirect href={ROUTES.signIn} />`. If `(auth)` sent a session-less `isRecovering` user to `/update-password`, that screen would immediately bounce them back to `/sign-in`, which is inside `(auth)`, which would send them to `/update-password` again. Resolving `unauthenticated` first makes the loop unreachable: a user with no session never enters the recovery branch at all.

{% alert type="warning" title="Do not reorder these branches" %}
Both layouts carry comments explaining why their first branch is first. The two orderings look inconsistent — `(home)` checks the recovery *mode* before the status, `(auth)` checks the status before the mode — but each is the only ordering that is safe for its own group. Reordering either one reintroduces a bug that is invisible until a user with the right combination of session, MFA enrolment and recovery marker hits it.
{% /alert %}

### Why `update-password` lives at the top level

`app/update-password.tsx` sits outside both groups on purpose, and the file says why: a guard redirects *from the layout*, which unmounts the entire group. Setting a new password changes the session mid-flow. If the screen lived inside `(auth)` or `(home)`, the session change would trip that group's guard and unmount the screen underneath the request that caused it. At the top level, only the root `Stack` is above it, and the screen does its own three-step check: `loading` → no session → not recovering.

The success path uses this deliberately. `NewPasswordForm` does not navigate on success — the `USER_UPDATED` event clears `isRecovering`, the screen's own `!isRecovering` check fires, and the redirect to `/` happens declaratively.

## `routes.config.ts`

`apps/native/features/core/config/routes.config.ts` is the single source for the paths the guards compare against. Layouts and auth screens import from it; they never inline a path string.

| Export | Contents | Used by |
| --- | --- | --- |
| `ROUTES` | `home: '/'`, `signIn: '/sign-in'`, `signUp: '/sign-up'`, `resetPassword: '/reset-password'`, `verifyResetCode: '/verify-reset-code'`, `mfaChallenge: '/mfa-challenge'`, `updatePassword: '/update-password'` | Every guard and auth-flow navigation. |
| `PUBLIC_AUTH_ROUTES` | sign-in, sign-up, reset-password, verify-reset-code | `isPublicAuthRoute()` — what `(auth)` renders with no session. |
| `RECOVERY_PERMITTED_ROUTES` | mfa-challenge only | `isRecoveryPermittedRoute()` — where a recovery session may stay inside `(auth)`. A recovering user with MFA has to pass the challenge before `update-password` will accept the new password. |
| `MFA_NEXT_ROUTES` | update-password only | An allowlist for the `next` search param on `/mfa-challenge`. |

`MFA_NEXT_ROUTES` is the one worth understanding. `NewPasswordForm` catches GoTrue's `insufficient_aal` error and redirects to `/mfa-challenge?next=/update-password`. On success, `mfa-challenge.tsx` does not trust the param — it runs `MFA_NEXT_ROUTES.find((route) => route === next)` and navigates only on an exact match, so an attacker-supplied `next` cannot redirect the user anywhere the kit did not sanction.

The two `is*Route` helpers exist because the arrays are `as const`: their literal element types are too narrow to accept a `usePathname()` string, so each helper widens to `readonly string[]` before calling `.includes()`.

{% alert type="warning" title="The `satisfies Record<string, Href>` on ROUTES checks nothing today" %}
The file ends with `as const satisfies Record<string, Href>`, and a comment admitting the constraint is inert. It is: `apps/native/tsconfig.json` lists `.expo/types/**/*.ts` under `include` but also lists `.expo` under `exclude`, and `exclude` filters `include`. The generated route declarations therefore never enter the program, `ExpoRouter.__routes` stays an empty interface, and `Href` falls back to its default `string | HrefObject` — which every string literal satisfies.

You can confirm this yourself: `npx tsc --listFilesOnly -p apps/native/tsconfig.json` lists no file under `apps/native/.expo/`.

**Consequence:** a typo in `ROUTES` is a runtime 404, not a type error. Verify route strings by hand when you edit that file. The same limitation applies to every `Href`-typed value in the app, including the helpers in `lib/workspace/navigation.ts`.
{% /alert %}

## Typed routes

`apps/native/app.json` sets `experiments.typedRoutes: true`. With it, `expo start` writes a `router.d.ts` into `.expo/types/` that enumerates the real route tree and augments `ExpoRouter.__routes`, which narrows `Href`, `router.push()`, `<Link href>` and `useLocalSearchParams` to your actual routes and their params.

The flag is on, and the file is generated. As explained above it is not currently reaching the type checker because of the `tsconfig.json` exclude. Editors that type-check with their own settings may still surface the narrowed types; `pnpm typecheck` does not. Treat route strings as unchecked until you have verified otherwise in your own setup.

If you do wire the generated types in and route names go stale after a rename, `pnpm clean:native:cache` drops `.expo` and the `tsbuildinfo` — see `docs/native/troubleshooting.mdoc`. `app.json` is one of the kit's configuration surfaces; `docs/native/config-requirements.mdoc` covers the rest and what a change to each one forces elsewhere.

## Tabs

Both account trees use `NativeTabs` from `expo-router/unstable-native-tabs`, which renders the platform's own tab bar (UITabBar on iOS, the Material bottom bar on Android).

| Tree | Layout file | Tabs, in bar order | Trigger names |
| --- | --- | --- | --- |
| Personal | `apps/native/app/(home)/(user)/(tabs)/_layout.tsx` | Home, Settings | `(home)`, `settings` |
| Team | `apps/native/app/(home)/[account]/(tabs)/_layout.tsx` | Home, Settings, Members | `(home)`, `settings`, `members` |

Members is the team tree's only extra tab; everything else is structurally identical. Tab labels come from `common.routes.*` in the message catalogue, and icons are declared per platform on each trigger (`sf="house" md="home"`).

The tab bar does not read the React Navigation theme — it builds its appearance only from explicit props — so both layouts pass `backgroundColor`, `iconColor` and `labelStyle` from `useThemeColors()`. If you add a tab and its colours look wrong in dark mode, that is why.

### Each tab owns its header

The native tab bar provides no shared header, so every tab supplies its own. `apps/native/components/tab-stack.tsx` is the shared wrapper:

```tsx
// Module-scoped so it's a stable reference — an inline renderer would remount
// the header on every navigation.
function renderHeader(props: HomeHeaderProps) {
  return <HomeHeader {...props} />;
}

export function TabStack({ titleKey, children }: TabStackProps) {
  const t = useTranslations('common');

  return (
    <Stack>
      <Stack.Screen
        name="index"
        options={{ title: tDynamic(t, titleKey, titleKey), header: renderHeader }}
      />
      {children}
    </Stack>
  );
}
```

Two things to keep intact:

- **`renderHeader` is module-scoped.** An inline `header={(props) => <HomeHeader {...props} />}` creates a new function identity on every render, and React Navigation treats that as a new component type — the header unmounts and remounts on every navigation, losing its state and flashing.
- **`TabStack` applies `HomeHeader` to `index` only.** Screens you push on top of a tab get the default native-stack header with a back button, and modals set `headerShown: false`. That is the intended split, not an oversight.

`HomeHeader` (`apps/native/components/home-header.tsx`) renders the app logo, the workspace-switcher pill (hidden entirely when `teamAccountsEnabled` is off) and the user-menu button. It declares an explicit height of `insets.top + 56`, because a custom native-stack header is measured asynchronously and a content-implied height makes the screen drop a frame on iOS.

## Modals and presentation

Presentation is declared by the *parent* layout's `<Stack.Screen options>`, never by the screen file. Everything the kit ships:

| Route | Presentation | Header | Why |
| --- | --- | --- | --- |
| `(home)/user-menu` | `modal` | none | Full-height sheet of account actions. |
| `(home)/theme` | `formSheet` | none | Short list of three options. |
| `(home)/workspace-switcher` | `formSheet` + `sheetAllowedDetents: 'fitToContents'` | none | Height follows the number of workspaces instead of snapping to a fixed detent. |
| `(home)/new-team` | `modal` | Android only | A form; iOS draws the heading in-content, Android in the native header. |
| `(home)/invitations` | **pushed** | shown, `headerBackButtonDisplayMode: 'minimal'` | See below. |
| `[account]/members-edit-*-role` | `transparentModal`, `animation: 'fade'` | none | The backdrop has to cover the bottom tab bar. |
| `settings/change-password`, `settings/delete-account`, `settings/edit-team-name`, `settings/leave-team` | iOS `formSheet` + `fitToContents`, Android `modal` | Android only | Android has no form-sheet equivalent. |
| `settings/mfa-enroll` | `modal` | Android only | Taller content than a fitted sheet. |
| `settings/mfa` | pushed | default back header | An ordinary sub-screen. |
| `members/invite` | iOS `fullScreenModal`, Android `modal` | Android only | Full-screen, not a form sheet, so the inline role `Select` anchors correctly. |

### The `headerShown: Platform.OS === 'android'` pattern

Several modals carry `headerShown: Platform.OS === 'android'` together with a `title`. The screen body then mirrors the branch — `new-team.tsx` renders its heading `<Text>` only `{Platform.OS === 'ios' ? … : null}`. The reason is platform convention: an iOS sheet reads as a card whose title belongs in the content, while an Android modal expects a title in the app bar. Copy both halves of the branch if you add a modal, or the title appears twice on one platform and not at all on the other.

### Why `invitations` is not a modal

`app/(home)/invitations.tsx` is the one sibling of `user-menu`/`theme`/`new-team` that is a plain pushed screen, and the layout comment states the reason:

```tsx
{/* Not a modal, unlike its siblings: `index` can make this the stack root,
    and a modal over nothing has no back affordance. `minimal` because the
    default back title is the previous route's name — here the raw `(user)`. */}
```

`app/(home)/index.tsx` redirects a user with zero team accounts and at least one pending invitation straight to `/(home)/invitations`. On that path the screen *is* the first entry in the stack. A modal presented over nothing has no card behind it to dismiss to and no back button, so the user would be trapped. As a pushed screen with `headerShown: true` it always has a working header. `headerBackButtonDisplayMode: 'minimal'` gives a chevron with no label, because the default label is the previous route's name — which here is the raw group name `(user)`.

The same reasoning applies to anything you add that a redirect can make the stack root.

## Navigating between workspaces

`apps/native/lib/workspace/navigation.ts` centralises every cross-tree destination so no screen builds one inline:

| Export | Returns |
| --- | --- |
| `PERSONAL_HOME` | `'/(home)/(user)/(tabs)/(home)'` |
| `NEW_TEAM` | `'/(home)/new-team'` |
| `teamHref(slug)` | `{ pathname: '/(home)/[account]/(tabs)/(home)', params: { account: slug } }` |
| `teamSettingsHref(slug)` | the same for `.../settings` |
| `resolveWorkspaceTarget(accounts, selection)` | The tree to land in, given the loaded memberships and the persisted selection |

`resolveWorkspaceTarget` is the shared decision function and is called from four places: `(home)/index.tsx` on landing, `(user)/_layout.tsx` when teams-only mode makes the personal tree invalid, `[account]/_layout.tsx` when the slug is not a current membership, and `invitations.tsx` after accepting. It honours a persisted team only when that slug is still in the loaded membership list, and it respects the `teamAccountsEnabled` / `teamAccountsOnly` feature flags — two of the flags that change which parts of this tree are reachable at all. `docs/native/config-requirements.mdoc` lists every flag and what setting it forces elsewhere. Because resolution is one function, a revoked membership, a stale persisted slug and a flag change all produce the same landing decision.

Navigating away from a modal follows a fixed sequence — `router.dismiss()` first, then `router.replace()` — so the modal is not left in the stack under the new tree. Workspace resolution, persistence, and the switcher UI are owned by `docs/native/workspaces.mdoc`.

## Walkthrough: add a screen

The example: a **Notification preferences** sub-screen under the personal Settings tab, reached from a row on the settings index.

### 1. Create the route file

The file's location in `app/` *is* the route. A sub-screen of personal settings goes next to the existing ones:

```tsx
// apps/native/app/(home)/(user)/(tabs)/settings/notifications.tsx
import { ScrollView } from 'react-native';

import { NotificationPreferences } from '~/features/accounts/components/notification-preferences';

export default function NotificationsScreen() {
  return (
    <ScrollView
      className="bg-background flex-1"
      contentContainerClassName="p-4"
    >
      <NotificationPreferences />
    </ScrollView>
  );
}
```

Keep the route file thin. It composes; the feature component under `features/` owns the data and the UI. That layering is the subject of `docs/native/project-structure.mdoc`.

### 2. Give it a header

Do nothing, and the screen inherits the default native-stack header with a back button — which is correct for a pushed sub-screen. `TabStack` only attaches `HomeHeader` to `index`, so there is no conflict.

To set the title or change the presentation, add a `<Stack.Screen>` to the tab's layout. It goes inside `TabStack`, which forwards children into its `Stack`:

```tsx
// apps/native/app/(home)/(user)/(tabs)/settings/_layout.tsx
<TabStack titleKey="routes.settings">
  <Stack.Screen
    name="notifications"
    options={{
      title: t('notificationPreferences'),
      headerBackButtonDisplayMode: 'minimal',
    }}
  />
  {/* …existing change-password, mfa, mfa-enroll, delete-account screens */}
</TabStack>
```

`name` is the file name without its extension. If you want a sheet instead of a push, copy the presentation block from `change-password` — including the `headerShown: Platform.OS === 'android'` half and the matching in-content heading.

### 3. Navigate to it

Group segments are invisible in URLs, so the route is `/settings/notifications`. Add a row to `app/(home)/(user)/(tabs)/settings/index.tsx` alongside the existing ones:

```tsx
<SettingsLinkRow
  icon={Bell}
  title={t('notificationPreferences')}
  description={t('notificationPreferencesDescription')}
  onPress={() => router.push('/settings/notifications')}
/>
```

`SettingsLinkRow` (`apps/native/features/makerkit/components/settings-link-row.tsx`) takes a `LucideIcon`, a title, an optional description and an `onPress`, and renders the chevron row used throughout settings.

Had this been a team screen, the dynamic segment would force the object form: `router.push({ pathname: '/(home)/[account]/(tabs)/settings/notifications', params: { account: slug } })`.

### 4. Add the title to the catalogue

`notificationPreferences` does not exist in web's `account.json`, so it is an **addition**, not an override — it goes in `apps/native/lib/i18n/additions/en/account.json`:

```json
{
  "notificationPreferences": "Notification preferences",
  "notificationPreferencesDescription": "Choose which alerts this device receives."
}
```

Then run `pnpm i18n:native` to regenerate `lib/i18n/messages.generated.ts`, which is committed and never hand-edited. Which tree a key belongs in — additions versus overrides — and what happens if you pick the wrong one is covered in `docs/native/i18n.mdoc`. Do not skip that decision; putting a key in the wrong tree is an error, which is what makes upstream drift detectable.

### 5. Rebuild? No

Metro resolves the route tree at runtime from the files on disk. Save the file and the new route is live over Fast Refresh — no `expo prebuild`, no new dev client. A native rebuild is only needed when you change something the native project embeds: an Expo config plugin, `app.json` native keys, or a dependency with native code. See `docs/native/building-shipping.mdoc`.

If the route does not appear at all, clear Metro's cache with `pnpm start:native:clear` before assuming anything is wrong with the file.

## Walkthrough: add a tab

Adding a tab is the same work plus one entry in the tab bar. Say you want a **Projects** tab in the team tree.

1. **Create the tab directory** with a layout and an index:

   ```
   apps/native/app/(home)/[account]/(tabs)/projects/_layout.tsx
   apps/native/app/(home)/[account]/(tabs)/projects/index.tsx
   ```

2. **Make the layout a `TabStack`**, so the tab's root gets `HomeHeader` like every other tab:

   ```tsx
   // apps/native/app/(home)/[account]/(tabs)/projects/_layout.tsx
   import { TabStack } from '~/components/tab-stack';

   export default function TeamProjectsLayout() {
     return <TabStack titleKey="routes.projects" />;
   }
   ```

   `titleKey` is typed as a key of the `common` namespace, and `common.routes.*` is where the existing tab titles live. Web's `common.json` has no `routes.projects`, so it is an addition — nested under the group it belongs to, in `apps/native/lib/i18n/additions/en/common.json`:

   ```json
   { "routes": { "projects": "Projects" } }
   ```

   The generator merges key by key, so nesting an addition inside a group web already defines is expected. Run `pnpm i18n:native` before the new `titleKey` will type-check. `TabStack` resolves the key through `tDynamic`, which falls back to the raw key rather than throwing, so a forgotten regeneration shows up as a literal `routes.projects` in the header.

3. **Register the trigger** in the tabs layout. Position in the file is position in the bar:

   ```tsx
   // apps/native/app/(home)/[account]/(tabs)/_layout.tsx
   <NativeTabs.Trigger name="projects">
     <NativeTabs.Trigger.Icon sf="folder" md="folder" />
     <NativeTabs.Trigger.Label>{t('routes.projects')}</NativeTabs.Trigger.Label>
   </NativeTabs.Trigger>
   ```

   `name` must match the directory name. `sf` is an [SF Symbol](https://developer.apple.com/sf-symbols/) name for iOS, `md` a Material icon name for Android — the tab bar is the platform's own, so it takes platform icon names rather than the Lucide components used everywhere else in the app.

4. **Decide whether the personal tree gets it too.** The two `(tabs)/_layout.tsx` files are independent. A team-only tab is added to `[account]/(tabs)/` alone; a tab both trees need is added twice, and the tab directory duplicated. There is no shared tab configuration, which is deliberate: the trees differ (Members exists only for teams) and a shared config would need a conditional for every difference.

A new tab needs no rebuild either — but the tab bar is a native view, so give Fast Refresh a moment, and reload the app if the bar does not pick up the new trigger.
