Skip to content

Routing

Tomoda uses Expo Router — a file-based router built on React Navigation. Files under frontend/app/ become routes; their on-disk shape is the URL shape.

Conventions

File / folder Meaning
app/foo.tsx Route at /foo
app/foo/index.tsx Route at /foo (folder form)
app/foo/_layout.tsx Layout wrapping every child route (renders a <Stack/>, <Tabs/>, etc.)
app/foo/[id].tsx Dynamic segment, accessible via useLocalSearchParams()
app/(group)/... Route group — folder ignored in the URL, used purely to share layout / organize
app/+html.tsx Web-only custom HTML shell (head, meta, viewport)
app/index.tsx The root route /

The root layout app/_layout.tsx wraps every screen with provider context (see State Management) and renders a single <Stack/>.

Top-level route map

app/_layout.tsx (providers + auth gate via Stack.Protected) is the parent. The consumer-facing tab sections live inside a (tomoda) group whose layout renders an <expo-router> <Tabs tabBar={() => null}> navigator with the default tab bar hidden. The chrome (mobile bottom dock + web sidebar) is painted by AppNav. Each tab keeps its own internal stack, so swipe-back and nested back navigation stay scoped to the active tab and tab swaps do not accumulate root history.

Route-agnostic screens (mount one screen under several tabs)

Because each tab owns its own stack, a screen deep-linked across tabs cannot router.back() to the origin — "back" pops the target tab's stack and lands on that tab's root, not the caller. When a screen must be reachable from more than one tab (Events and Voyages open from both the hub's horizon and the planner), put its content in one shared component and mount thin route wrappers at each path:

  • components/<domain>/<Name>Screen.tsx — the screen itself: UI, data, and its own Page + header.
  • app/(tomoda)/hub/horizon/events.tsx and app/(tomoda)/plan/events.tsx — one line each (export { default } from '.../EventsScreen', or a wrapper that passes props).

Each wrapper inherits its own stack's back behavior, so opening Events from the planner keeps the user in the plan tab and "back" returns to the planner. Prefer this to cross-tab deep-linking patched up with router.back() fallbacks.

Keep shared data in the shared layer

Because the same component mounts in more than one stack (and Expo Router keeps both tab stacks alive), its server data must live in a TanStack Query hook (keyed by a *QueryKey factory) or a context — then every mount reads one cache: fetch once, no divergence, and WS updates patch all mounts. Only genuinely per-view UI state (scroll position) stays local. A hand-rolled useState fetch loop double-fetches and drifts between mounts.

Sibling groups outside (tomoda) host the surfaces that should NOT participate in the tab navigator: auth flows, the capture flow (native-only, full-screen modal), public share targets, and the partner / Tomoda-team shells. Which authed shell mounts is chosen by account type in Stack.Protected. The full inventory is below.

Route prefix Type Purpose Children
/ (index.tsx) screen Public landing (web) and redirect target. Authed users bounce to /discover (standard), /dashboard (partner), or /manager (Tomoda team).
/auth/* stack Sign-in, sign-up, recovery, onboarding. Mounted while !isAuthenticated \|\| profileIncomplete. login.tsx, register.tsx, forgot-password.tsx, complete-profile.tsx
(tomoda)/ group Consumer app shell. Layout renders <Tabs tabBar={() => null}> with animation: 'none'. Mounted for standard / synthetic accounts. Chrome painted by AppNav. discover/, plan/, search.tsx, connect/, notifications.tsx, hub/
/discover ((tomoda)/discover/) stack Discover tab. /discover renders the home view (greeting + feed); /discover/map renders the full map. The mobile center nav button morphs into a capture shutter while on either route (native-only). index.tsx, map.tsx
/plan/* ((tomoda)/plan/) stack Plan tab: the stash + plan canvas (index.tsx), plus in-tab Events / Voyages that render the same shared screens as horizon (route-agnostic, so back returns to the planner). index.tsx, events.tsx, voyages.tsx
/search ((tomoda)/search.tsx) screen Unified search tab (events, locations, people, NL intent) backed by the /discovery/search* endpoints.
/connect/* ((tomoda)/connect/) stack Messaging / DMs / new-chat flow. /connect redirects to /connect/inbox. index.tsx, inbox.tsx, new.tsx, [userId].tsx, chat/[id].tsx, chat/draft/[userId].tsx
/notifications ((tomoda)/notifications.tsx) screen Inbox of activity notifications.
/hub/* ((tomoda)/hub/) stack Profile + settings hub, plus the personal timeline (horizon) and friends / security sub-trees. index.tsx, profile.tsx, profile-edit.tsx, appearance.tsx, preferences.tsx, account.tsx, friends/, security/, horizon/
(capture)/* group Capture flow (no URL prefix), native-only. The layout Redirects to /discover on web, so the routes are unreachable from the web bundle. Entered by tapping the shutter the center dock button morphs into on /discover and /discover/map. camera.tsx, checkin.tsx, compose-moment.tsx, place.tsx, preview.tsx
(social)/* group Public share targets, available regardless of auth state for OG previews and direct deep links. events/[id].tsx, u/[handle].tsx, m/[id].tsx (moment), plans/[id].tsx, plans/join/[token].tsx
(partner)/* group Partner-account shell. Mounted when account_type === 'partner'; the layout renders <Tabs tabBar={() => null}>. AppNav switches to the partner nav. Partners use a linked standard account to reach the consumer app (see Authentication architecture → Linked accounts). dashboard/, members/, events/, hub/
(internal)/* group Tomoda team shell. Mounted when account_type === 'tomoda'. Hosts the manager surface, with role-gated tooling layered inside the layout as it ships. Tomoda team members use a linked standard account to reach the consumer app. manager/index.tsx
+html.tsx web-only Custom <html> shell for the web bundle.

auth/

auth/
├── _layout.tsx
├── login.tsx              -> /auth/login
├── register.tsx           -> /auth/register
├── forgot-password.tsx    -> /auth/forgot-password
└── complete-profile.tsx   -> /auth/complete-profile

Supports Email/Password, Google, Apple, Passkey, and OTP-based flows (driven from contexts/AuthContext.tsx).

connect/

connect/
├── _layout.tsx
├── index.tsx                 -> /connect            (redirects to /connect/inbox)
├── inbox.tsx                 -> /connect/inbox
├── new.tsx                   -> /connect/new
├── [userId].tsx              -> /connect/:userId     (DM resolver: opens or drafts a room)
└── chat/
    ├── [id].tsx              -> /connect/chat/:id    (active room)
    └── draft/[userId].tsx    -> /connect/chat/draft/:userId

Chat rooms open via the dynamic chat/[id].tsx route; the per-room WebSocket connection is established in that screen. See Real-time. /connect/:userId resolves an existing room for that friend (or spins a draft) and redirects into the chat route.

hub/

The settings/profile hub, which also hosts the personal timeline (horizon) and the friends / security sub-trees:

hub/
├── _layout.tsx
├── index.tsx                 -> /hub
├── profile.tsx               -> /hub/profile
├── profile-edit.tsx          -> /hub/profile-edit
├── appearance.tsx            -> /hub/appearance       (theme picker)
├── preferences.tsx           -> /hub/preferences
├── account.tsx               -> /hub/account
├── friends/
│   ├── index.tsx             -> /hub/friends
│   └── close.tsx             -> /hub/friends/close    (close-friends list)
├── security/
│   ├── index.tsx             -> /hub/security
│   ├── change-email.tsx      -> /hub/security/change-email
│   └── change-password.tsx   -> /hub/security/change-password
└── horizon/
    ├── events.tsx            -> /hub/horizon/events   (wraps components/horizon/EventsScreen)
    └── voyages.tsx           -> /hub/horizon/voyages  (wraps components/horizon/VoyagesScreen)

events.tsx / voyages.tsx here are thin wrappers around the shared components/horizon/{EventsScreen,VoyagesScreen} — the same screens plan/events.tsx / plan/voyages.tsx mount (see Route-agnostic screens).

(social)/ — public share targets

(social)/
├── events/[id].tsx        -> /events/:id       (no group prefix)
├── u/[handle].tsx         -> /u/:handle
├── m/[id].tsx             -> /m/:id             (moment)
├── plans/[id].tsx         -> /plans/:id         (shared plan)
└── plans/join/[token].tsx -> /plans/join/:token (share-link join)

These routes are public, reachable without authentication so they can render share previews and OG metadata. The auth gate keeps index and (social) always mounted.

/u/:handle accepts either a username or a UUID. The backend resolves either form; the screen canonicalizes a UUID landing to the username path via router.replace once the profile loads so the address bar shows the pretty link. The reserved username list (backend/internal/utils/username.go::IsReservedUsername) keeps usernames from colliding with top-level route segments (u, discover, hub, etc.).

(capture)/

(capture)/
├── _layout.tsx            -> wraps in CaptureProvider; Redirects to /discover on web
├── camera.tsx             -> capture camera
├── place.tsx              -> nearby-location picker
├── checkin.tsx            -> check-in composer
├── compose-moment.tsx     -> moment composer
└── preview.tsx            -> capture preview / confirm

The group is native-only: the layout Redirects to /discover on web, so these routes never render in the web bundle. CaptureContext (provided in (capture)/_layout.tsx) holds the in-flight publish (picked location, companions, rating, captured media) so back-navigation preserves state; it resets on publish or cancel.

The check-in publish path additionally calls POST /events/:id/checkin for a nearby active event, which proximity-checks the user's active event RSVPs and stamps the participant row on a match. Best-effort; never fails the publish. See Events service.

(internal)/

(internal)/manager/index.tsx   -> /manager

The Tomoda-team shell, mounted only for account_type === 'tomoda'. Screens are role-gated inside the layout.

Auth gate

The root layout (frontend/app/_layout.tsx) uses Stack.Protected to declaratively choose which top-level groups exist at any given moment. When a guard flips (login, logout, profile completion), the navigator auto-navigates to the first available screen in the newly-mounted branch, so no manual router.replace is required for the broad transitions.

const profileIncomplete = !!user && (!user.username || !user.date_of_birth);

<Stack>
  {/* Always available, public surfaces */}
  <Stack.Screen name="index" />
  <Stack.Screen name="(social)" />

  {/* Unauth / onboarding shell */}
  <Stack.Protected guard={!isAuthenticated || profileIncomplete}>
    <Stack.Screen name="auth" />
  </Stack.Protected>

  {/* Authed shells, chosen by account type. Standard/synthetic accounts
    * get the consumer surface plus the capture flow; partner and Tomoda
    * accounts each mount their own shell instead. */}
  <Stack.Protected guard={authed && (accountType === 'standard' || accountType === 'synthetic')}>
    <Stack.Screen name="(tomoda)" options={{ animation: 'none' }} />
    <Stack.Screen name="(capture)" options={{ presentation: 'fullScreenModal' }} />
  </Stack.Protected>

  <Stack.Protected guard={authed && accountType === 'partner'}>
    <Stack.Screen name="(partner)" options={{ animation: 'none' }} />
  </Stack.Protected>

  <Stack.Protected guard={authed && accountType === 'tomoda'}>
    <Stack.Screen name="(internal)" options={{ animation: 'none' }} />
  </Stack.Protected>
</Stack>

A small effect alongside Stack.Protected covers narrow transitions the declarative guards cannot express:

  • Authed users landing on / or /auth/* get bounced to their shell's home (/discover for standard, /dashboard for partner, /manager for Tomoda team), so the marketing landing never sticks for a signed-in user.
  • Authed users whose profile is incomplete are routed specifically to /auth/complete-profile (the auth group is mounted but the navigator needs to be steered to the right child).
Caller state Mounted shell Default screen
Unauthenticated index, (social), auth /auth/login
Authed, profile incomplete index, (social), auth /auth/complete-profile
Authed standard / synthetic index, (social), (tomoda), (capture) /discover
Authed partner index, (social), (partner) /dashboard
Authed tomoda index, (social), (internal) /manager

Partner and Tomoda-team members reach the consumer surface through a linked standard account (separate JWT, re-auth on switch). See Authentication architecture → Frontend authorization shell for the multi-shell pattern, role-based screen gating, and account switching.

Default landing

The signed-in landing route is /discover. It renders the discovery home view. The full map lives at /discover/map and is reached via the "Open map" affordance on the home view or the segmented control in the page header. Both routes are treated as the Discover tab in AppNav, so the mobile center button morphs into the capture shutter on either one.

Where to go next