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.tsxSegment 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:
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.PortalHostandToastersit 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
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.
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.
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().
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.
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:
// 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:
renderHeaderis module-scoped. An inlineheader={(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.TabStackappliesHomeHeadertoindexonly. Screens you push on top of a tab get the default native-stack header with a back button, and modals setheaderShown: 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:
{/* 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:
// apps/native/app/(home)/(user)/(tabs)/settings/notifications.tsximport { 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.
Create the tab directory with a layout and an index:
apps/native/app/(home)/[account]/(tabs)/projects/_layout.tsxapps/native/app/(home)/[account]/(tabs)/projects/index.tsxMake the layout a
TabStack, so the tab's root getsHomeHeaderlike every other tab:// apps/native/app/(home)/[account]/(tabs)/projects/_layout.tsximport { TabStack } from '~/components/tab-stack';export default function TeamProjectsLayout() {return <TabStack titleKey="routes.projects" />;}titleKeyis typed as a key of thecommonnamespace, andcommon.routes.*is where the existing tab titles live. Web'scommon.jsonhas noroutes.projects, so it is an addition — nested under the group it belongs to, inapps/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:nativebefore the newtitleKeywill type-check.TabStackresolves the key throughtDynamic, which falls back to the raw key rather than throwing, so a forgotten regeneration shows up as a literalroutes.projectsin the header.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>namemust match the directory name.sfis an SF Symbol name for iOS,mda 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.Decide whether the personal tree gets it too. The two
(tabs)/_layout.tsxfiles 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.