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¶
flowchart LR
Root[app/_layout.tsx<br/>providers + auth gate]
Root --> Auth[auth/]
Root --> Home[home/]
Root --> Disc[discover/]
Root --> Hor[horizon/]
Root --> Con[connect/]
Root --> Hub[hub/]
Root --> Notif[notifications/]
Root --> Mom[\(moments\)/]
Root --> Soc[\(social\)/]
Root --> Adm[\(admin\)/]
Root --> Idx[index.tsx]
Root --> Html[+html.tsx]
| Route prefix | Type | Purpose | Children |
|---|---|---|---|
/ (index.tsx) |
screen | Public landing / redirect target | — |
/auth/* |
stack | Sign-in, sign-up, recovery, onboarding | login.tsx, register.tsx, forgot-password.tsx, complete-profile.tsx |
/home |
screen | Authenticated home (map-first) | index.tsx |
/discover |
stack | Default landing after login; the discovery feed | index.tsx |
/horizon/* |
stack | Personal timeline: events, voyages, waypoints | index.tsx, events.tsx, voyages.tsx, waypoints.tsx |
/connect/* |
stack | Messaging / DMs / new-chat flow | index.tsx, inbox.tsx, new.tsx, chat/[id].tsx, chat/draft.tsx, [userId].tsx |
/hub/* |
stack | Profile + settings hub | index.tsx, profile.tsx, profile-edit.tsx, appearance.tsx, preferences.tsx, account.tsx, friends/, security/ |
/notifications |
screen | Inbox of activity notifications | index.tsx |
(moments)/* |
group | Moments capture flow (no URL prefix) | capture.tsx, publish.tsx |
(social)/* |
group | Public share targets (no URL prefix) | events/[id].tsx, users/[id].tsx |
(admin)/* |
group | Admin tooling, gated by role | manager/dashboard.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
├── inbox.tsx -> /connect/inbox
├── new.tsx -> /connect/new
├── [userId].tsx -> /connect/:userId (DM landing)
└── chat/
├── [id].tsx -> /connect/chat/:id (active room)
└── draft.tsx -> /connect/chat/draft
Chat rooms are opened via the dynamic chat/[id].tsx route; the WebSocket connection is established in that screen — see Real-time.
horizon/¶
horizon/
├── _layout.tsx
├── index.tsx -> /horizon (landing)
├── events.tsx -> /horizon/events
├── voyages.tsx -> /horizon/voyages
└── waypoints.tsx -> /horizon/waypoints
hub/¶
The settings/profile hub:
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
└── security/
├── index.tsx -> /hub/security
├── change-email.tsx -> /hub/security/change-email
└── change-password.tsx
(social)/ — public share targets¶
(social)/
├── events/[id].tsx -> /events/:id (no group prefix)
└── users/[id].tsx -> /users/:id
These routes are public — they're reachable without authentication so they can render share previews and OG metadata. The auth gate explicitly whitelists /events/... and /users/....
(moments)/¶
(moments)/
├── capture.tsx -> /capture
└── publish.tsx -> /publish
(admin)/¶
(admin)/manager/dashboard.tsx -> /manager/dashboard
Admin pages are role-gated at the screen level; the route itself is reachable but the screen redirects non-admins.
Auth gate¶
The root layout (frontend/app/_layout.tsx) runs an effect on every navigation change to enforce three rules:
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === 'auth';
const isPublicRoute =
segments.length === 0 ||
pathname.startsWith('/events/') ||
pathname.startsWith('/users/');
if (!isAuthenticated) {
if (!inAuthGroup && !isPublicRoute) {
router.replace('/auth/login');
}
} else {
if (user && (!user.username || !user.date_of_birth)) {
// profile incomplete → finish onboarding
const isCompleteProfilePage =
segments[0] === 'auth' && segments[1] === 'complete-profile';
if (!isCompleteProfilePage) {
router.replace('/auth/complete-profile');
}
} else if (inAuthGroup || segments.length === 0) {
router.replace('/discover');
}
}
}, [isAuthenticated, segments, isLoading, user]);
| Condition | Redirect |
|---|---|
| Not authenticated + not on auth page + not on public share page | → /auth/login |
Authenticated but username or date_of_birth missing |
→ /auth/complete-profile |
Authenticated and on /auth/* or / |
→ /discover |
Default landing
The signed-in landing route is /discover, not /home. /home exists but is reached explicitly via the nav dock.
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