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.

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.tsx, 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.tsx) screen Plan tab: the stash + plan canvas surface.
/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
    └── voyages.tsx           -> /hub/horizon/voyages

(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