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 (/discoverfor standard,/dashboardfor partner,/managerfor 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(theauthgroup 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¶
- State Management — the providers that wrap every route
- API Client — what each screen actually calls
- Components — what's inside each route