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.

The Expo app routes with expo-router: 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 asCalledAppears in the URL?What it does
settings/Plain segmentYesA literal path segment. settings/index.tsx is /settings.
(user)/Route groupNoOrganisational 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 segmentYes, as a valueMatches 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:

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: AuthProviderReactQueryProviderThemePreferenceProviderI18nProviderSafeAreaProviderKeyboardProviderGestureHandlerRootView.

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:

ValueMeaning
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: trueA 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

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

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.

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.

ExportContentsUsed by
ROUTEShome: '/', 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_ROUTESsign-in, sign-up, reset-password, verify-reset-codeisPublicAuthRoute() — what (auth) renders with no session.
RECOVERY_PERMITTED_ROUTESmfa-challenge onlyisRecoveryPermittedRoute() — 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_ROUTESupdate-password onlyAn 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().

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).

TreeLayout fileTabs, in bar orderTrigger names
Personalapps/native/app/(home)/(user)/(tabs)/_layout.tsxHome, Settings(home), settings
Teamapps/native/app/(home)/[account]/(tabs)/_layout.tsxHome, 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:

// 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:

RoutePresentationHeaderWhy
(home)/user-menumodalnoneFull-height sheet of account actions.
(home)/themeformSheetnoneShort list of three options.
(home)/workspace-switcherformSheet + sheetAllowedDetents: 'fitToContents'noneHeight follows the number of workspaces instead of snapping to a fixed detent.
(home)/new-teammodalAndroid onlyA form; iOS draws the heading in-content, Android in the native header.
(home)/invitationspushedshown, headerBackButtonDisplayMode: 'minimal'See below.
[account]/members-edit-*-roletransparentModal, animation: 'fade'noneThe backdrop has to cover the bottom tab bar.
settings/change-password, settings/delete-account, settings/edit-team-name, settings/leave-teamiOS formSheet + fitToContents, Android modalAndroid onlyAndroid has no form-sheet equivalent.
settings/mfa-enrollmodalAndroid onlyTaller content than a fitted sheet.
settings/mfapusheddefault back headerAn ordinary sub-screen.
members/inviteiOS fullScreenModal, Android modalAndroid onlyFull-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:

{/* 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.

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

ExportReturns
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:

// 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:

// 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:

<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:

{
"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:

    // 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:

    { "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:

    // 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 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.