Mobile architecture
The native member app is fitkit-mobile — a standalone Expo (managed workflow) React Native app that ships to the iOS App Store and Google Play. It mirrors the member-facing surfaces of apps/web and layers on native value-adds: QR-camera check-in, GPS check-in, push notifications, offline-tolerant cache, haptics, and gesture-driven sheets.
Where the code lives
The mobile app is a separate repository, not part of this monorepo. It sits next to the monorepo on disk during development:
~/dev/
├── fitkit/ this monorepo (api, web, admin, marketing, minisites, docs)
└── fitkit-mobile/ the Expo app (separate repo, separate CI, separate deploy)Why split off? Three reasons:
- Toolchain divergence. Expo SDK pins React Native / React versions tightly. Mobile is on RN 0.81 + React 19 via Expo SDK 55; the monorepo’s pnpm workspace pins are not always compatible.
- Build pipeline difference. Mobile builds through EAS (Expo’s cloud builders) with native code generation. Web/API build through Nx / Vercel / Railway. Sharing a workspace forced both pipelines into mutual constraints.
- Native artifacts.
ios/andandroid/directories contain generated native projects; they’re noisy in PRs that touch nothing else.
The two repos stay in sync through @fitkit/shared — see “Shared code” below.
Stack at a glance
| Layer | Library | Notes |
|---|---|---|
| Runtime | Expo SDK 55, React Native 0.81, React 19 | New Architecture enabled (newArchEnabled: true) |
| Navigation | expo-router (file-based) | Typed routes (experiments.typedRoutes: true) |
| Styling | nativewind 4 + Tailwind tokens | dark: follows system color scheme; CSS variables in global.css |
| UI primitives | @rn-primitives/* (avatar, tabs, progress, separator, slot) | RN equivalents of Radix |
| Auth | @clerk/clerk-expo + @clerk/localizations | Token cache via expo-secure-store |
| Data fetching | @tanstack/react-query 5 + @tanstack/react-query-persist-client | AsyncStorage persister for offline tolerance |
| HTTP | native fetch wrapped by useApi() | 10s in-memory Clerk JWT cache + single 401 retry |
| Realtime | socket.io-client | transports: ['websocket'], AppState-aware reconnect |
| Camera / barcode | expo-camera | QR scan for check-in |
| Push | expo-notifications | Expo push tokens registered with API on sign-in |
| Storage | @react-native-async-storage/async-storage, expo-secure-store | AsyncStorage = cache; SecureStore = secrets |
| Crash + replay | @sentry/react-native | Mobile replay enabled; 0 traces sample, 1.0 replay-on-error |
| Analytics | posthog-react-native | EU region by default |
| File I/O | expo-file-system, expo-image-manipulator, expo-image-picker | Form attachments, progress photos |
| Localization | expo-localization + dictionaries from @fitkit/shared | Hebrew (RTL) is default, see “i18n” below |
| Haptics | expo-haptics | Wrapped in useHaptics() |
| Status bar | expo-status-bar |
Shared code: @fitkit/shared
This is the contract between the API/web/mobile. The mobile app consumes it as a versioned npm package, not as a workspace dependency:
- Source:
libs/shared/in this monorepo. - Published as:
@desmotech/fitkit-sharedon GitHub Packages. - Aliased in mobile as:
@fitkit/shared(via npm: protocol inpackage.json), so imports read the same as in this repo. - Auto-publish:
.github/workflows/publish-shared.ymlpatches on everymainpush that toucheslibs/shared/**. Manual minor/major bumps viaworkflow_dispatch.
Mobile installs require a GitHub PAT with read:packages exported as $GITHUB_TOKEN — the mobile repo’s .npmrc reads it at install time. This is intentional: @fitkit/shared is private.
Bumping in mobile: pnpm up @fitkit/shared@latest (or pin a specific version). The package brings Zod schemas, TS types, locale dictionaries, and the platform-tier feature map.
Repo layout
fitkit-mobile/
├── app/ expo-router file routes (see below)
├── src/
│ ├── components/
│ │ ├── ui/ primitives — text, button, input, card, tabs, …
│ │ ├── fitkit/ product surfaces — hero header, stat tiles, PR row, …
│ │ ├── forms/ form-renderer + field renderers (compliance signing)
│ │ ├── schedule/ schedule cells, today-class-card
│ │ └── fk/ palette helpers, design tokens consumed at runtime
│ ├── hooks/ useApi, useSchedule, useForms, useWorkouts, useHaptics, …
│ ├── i18n/ config + dictionary glue (dictionaries live in @fitkit/shared)
│ ├── lib/ api.ts (env), secure-token-cache, query-keys, score, analytics
│ ├── providers/ I18nProvider, QueryProvider, ThemeProvider, AuthGate
│ └── types/ forms.ts (narrow types not in @fitkit/shared)
├── assets/ fonts + image assets bundled with the app
├── ios/ android/ generated by `expo prebuild` (committed for EAS)
├── app.config.ts Expo config (the single source of truth — not app.json)
├── eas.json EAS build profiles (development / preview / production)
└── metro.config.js Metro + NativeWind + Sentry config plugin wrapperRoutes
Expo Router under app/ — file-based, mirrors Next.js app router conventions (_layout.tsx for layouts, (group)/ for layout groups that don’t appear in the URL, +not-found.tsx for catch-all).
app/
├── _layout.tsx root: Sentry init, ClerkProvider, providers tree, fonts
├── index.tsx entry redirect (signed-in → /(tabs), signed-out → /(auth))
├── (auth)/
│ ├── _layout.tsx prevent signed-in users from re-entering
│ └── sign-in.tsx Clerk-backed sign-in
├── sign-up.tsx Clerk sign-up; also lands here from invite-ticket flow
├── onboarding/
│ ├── _layout.tsx
│ ├── accept-terms.tsx legal consent gate (TOS, privacy, fitness waiver)
│ └── complete-profile.tsx first-time profile completion
├── (tabs)/ 5-tab member shell, AuthGate-protected
│ ├── _layout.tsx tab bar + AuthGate
│ ├── index.tsx Home — hero, today's class, today's workout, recent PRs
│ ├── schedule/ Schedule tab
│ │ ├── index.tsx week view + class cards
│ │ ├── [id].tsx session detail (book, waitlist, check-in)
│ │ └── scan.tsx QR scanner (full-screen modal)
│ ├── workouts/ Workouts tab
│ │ ├── index.tsx assignment + library list
│ │ ├── [id]/
│ │ │ ├── index.tsx workout detail (sections, movements, log result)
│ │ │ ├── video.tsx embedded demo video viewer
│ │ │ └── exercise/[movementId].tsx per-movement detail + coach comments
│ ├── messages/ Messages tab (1:1 + announcements)
│ │ ├── index.tsx conversation list
│ │ └── [id].tsx conversation detail
│ └── profile/ Profile tab
│ ├── index.tsx settings landing
│ ├── personal.tsx account info, locale, sign-out
│ ├── notifications.tsx per-channel toggles + push permission state
│ ├── payments.tsx memberships + payment methods
│ ├── history.tsx workout history + PRs
│ ├── feedback.tsx in-app feedback → Resend
│ └── delete-account.tsx App Store policy: in-app account deletion
├── log/ standalone logging surfaces (modal-style)
│ ├── _layout.tsx
│ ├── index.tsx pick what to log
│ ├── lift.tsx log a lift (PR-aware)
│ ├── metric.tsx log a body metric
│ └── workout/[id].tsx log a workout result
├── forms/
│ ├── _layout.tsx
│ └── sign/[token].tsx token-gated public signing (no auth required)
├── announcements.tsx announcements list (push notification deep-link target)
├── checkin.tsx universal-link target for QR check-in URLs
└── +not-found.tsxRoute groups
(auth), (tabs), etc. are layout groups — they don’t appear in URLs. They exist so the auth screens, tab shell, and onboarding flow can each own their own layout chrome without polluting the URL.
AuthGate redirect chain
src/providers/auth-gate.tsx wraps (tabs)/_layout.tsx and mirrors the web’s protected layout redirect chain:
- Clerk not loaded → spinner
!isSignedIn→/(auth)/sign-in/users/meOR/legal/consents/statusstill loading → spinnerneedsLegalConsent→/onboarding/accept-termsisProfileIncomplete→/onboarding/complete-profile- Otherwise → render children
useNeedsLegalConsent hits /legal/consents/status directly rather than trusting user.pendingLegalConsents — that flag wasn’t reliably set on Clerk-invited accounts. See docs/features/legal/ for the underlying contract.
Providers
Composed in app/_layout.tsx, outer to inner:
| Provider | Purpose | Source |
|---|---|---|
ClerkProvider | Identity. Token cache wired to expo-secure-store so sessions survive cold start. | @clerk/clerk-expo |
GestureHandlerRootView | Required root for react-native-gesture-handler | react-native-gesture-handler |
SafeAreaProvider | Safe-area insets for notches, dynamic island, gesture nav | react-native-safe-area-context |
KeyboardProvider | Keyboard avoidance + animated scroll | react-native-keyboard-controller |
I18nProvider | Locale + RTL direction, dictionary t | src/providers/i18n-provider.tsx |
QueryProvider | React Query client + AsyncStorage persister + AppState-driven refetch | src/providers/query-provider.tsx |
ThemeProvider | Color scheme propagation | src/providers/theme-provider.tsx |
useAnalyticsIdentify() and usePushNotifications() mount inside the tree once Clerk has resolved — they need the session.
QueryProvider defaults
staleTime: 60s,gcTime: 30min,placeholderData: keepPreviousData,retry: 1,refetchOnWindowFocus: false.- Persistence to AsyncStorage with key
fitkit-rq-cache, throttled at 1s. AppStatelistener triggers refetch on transition toactive— closest mobile-equivalent ofrefetchOnWindowFocus.
I18nProvider and RTL
Mobile opts out of React Native’s automatic RTL flipping (I18nManager.allowRTL(false)). Layout direction is applied explicitly via flexDirection: 'row-reverse' driven by useI18n().dir. Reason: RN’s auto-flip composed with the explicit flips stacks negations and snaps LTR screens to RTL when isRTL got “stuck” after a Hebrew session. If I18nManager.isRTL is true on cold start, the provider resets it and calls Updates.reloadAsync() once.
Locale resolution: expo-localization device locale if it matches he|en|ru, else i18n.defaultLocale = 'he' (same default as web).
Auth — Clerk Expo
The mobile side mirrors the web auth model, with two platform-specific twists:
- Token cache via
expo-secure-store. Without this, Clerk drops sessions on every cold start.src/lib/secure-token-cache.tsimplementsTokenCachewithgetToken/saveToken. Corrupted entries are deleted and re-issued silently. - Sign-out drops the device push token.
Profile → Sign outcallsrevokeCurrentDeviceToken()beforesignOut()so the device stops receiving notifications meant for the previous user.
Universal sign-in flow (sign-in screen app/(auth)/sign-in.tsx):
- Email OTP, magic-link, or password (whatever the org enables in Clerk).
- Invitation acceptance: invites land at
https://app.fitkit.fit/sign-up?__clerk_ticket=...&__clerk_status=sign_up(Clerk’s redirect target). On Android the intent filter catchespathPrefix: '/sign-up'. On iOS the AASA file does the same. See FIT-188 for the assetlinks / AASA setup. - The same Clerk publishable key as web (
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).
API client — useApi()
src/hooks/use-api.ts. The mobile counterpart to web’s useApi. Behavior:
- 10-second in-memory cache of the Clerk JWT (Clerk JWTs are valid for 60s — the mobile hop adds latency, so a brief cache is worth it).
- On
401: refresh the token once and retry. Second401→signOut()+router.replace('/(auth)/sign-in'). - On non-2xx: parse
{ message }body if present, throwError(message). - Sets
X-Locale: <lang>on every request — the API uses it for locale-aware copy in notification payloads. - Base URL from
expo-constantsextra.apiUrl(set per EAS profile).
useApiQuery and useApiMutation wrap React Query so screens consume useQuery({ queryKey, queryFn }) with the bearer attached automatically.
Realtime
socket.io-client wired in a hook (see src/hooks/use-messages.ts, use-conversations.ts). Behaviors:
transports: ['websocket']only — the long-polling fallback fights with React Native’s event loop.- JWT attached on connect; rotated on reconnect.
AppStatelistener disconnects on background and reconnects on foreground. Otherwise iOS will snapshot a dead socket and freeze the UI on resume.- Channels:
conversation:<id>,org:<id>(typing, presence),user:<id>(DMs).
Push notifications
src/hooks/use-push-notifications.ts. Mounts once at the root. Lifecycle:
- Wait for Clerk session.
- If
Constants.expoConfig.extra.eas.projectIdis missing → warn + skip (dev builds without EAS env vars). - Request permission (
expo-notifications). iOS prompts; Android grants by default. - Fetch the device’s Expo push token via
getExpoPushTokenAsync({ projectId }). - On Android, create the
defaultchannel with vibration pattern + brand light color. POST /devices/registerwith{ token, platform }(auth: Clerk bearer). The API stores it on the user row and sends through Expo’s push service.- Foreground handler: banner + list + sound, no badge.
- Response listener:
data.route→router.push(route)for tap-through deep linking.
Module-level currentToken cache lets the sign-out flow call revokeCurrentDeviceToken() without re-rounding through Expo’s token API (which can hang briefly after Clerk has cleared credentials).
Simulators can’t mint Expo push tokens — getExpoPushTokenAsync throws and we swallow it silently. Test on a physical device.
See docs/features/push-notifications/ for the full server-side fan-out story.
Deep links and universal links
| Source | Destination | Auth model |
|---|---|---|
| QR code at gym (in-app scan) | app/(tabs)/schedule/scan.tsx → POST /sessions/:id/self-checkin | Bearer |
| QR code captured outside the app (Messages, email, web fallback) | Universal link → app/checkin.tsx | Bearer |
| Push notification tap | data.route → router.push(route) | Bearer |
| Form-signing link from WhatsApp/SMS | app/forms/sign/[token].tsx | Token-only (no auth) |
| Clerk invitation redirect | app/sign-up.tsx with ?__clerk_ticket=... | Clerk completes the flow |
iOS — Associated Domains
app.config.ts declares associatedDomains: ['applinks:app.fitkit.fit']. The AASA file lives at https://app.fitkit.fit/.well-known/apple-app-site-association (served by apps/web). See FIT-188.
Android — App Links
Intent filters in app.config.ts with autoVerify: true cover the four host paths that should open natively rather than fall through to the browser:
/checkin(+/he,/en,/rulocale prefixes)/sign-up(Clerk invitation redirect)/forms/sign(FIT-178 token-gated signing)
The assetlinks JSON at https://app.fitkit.fit/.well-known/assetlinks.json is required for auto-verify. See FIT-188.
i18n
Source dictionaries: @fitkit/shared (the same JSON files as apps/web/src/i18n/dictionaries/). The mobile app reads them through dictionaries named export and exposes via useI18n().
const { lang, dir, t, setLang } = useI18n();
// lang: 'he' | 'en' | 'ru'
// dir: 'ltr' | 'rtl'
// t: the resolved Dictionary
// setLang(next): persists locale + reloads if RTL <-> LTR flipA locale flip that changes direction calls Updates.reloadAsync() so the whole tree remounts with the new direction. A flip that preserves direction (e.g. en ↔ ru) is in-process.
Hard rule from CLAUDE.md: every user-facing string in the dictionary. Mobile-only screens (sign-up.tsx, checkin.tsx, scanner) MUST add keys to en.json, he.json, ru.json in @fitkit/shared and bump the package before referencing them.
Theming
NativeWind 4 maps Tailwind utilities to RN style props. Colors are CSS variables in global.css (--background, --foreground, --primary, etc.). dark: variants follow Appearance.getColorScheme() via ThemeProvider.
The useFKColors() hook returns resolved RGB strings for places where Tailwind classes don’t reach (e.g. <StatusBar />, gradients, Reanimated worklets).
Offline behavior
- Query persistence.
PersistQueryClientProviderwrites the React Query cache to AsyncStorage. On cold start, last-seen data renders instantly; refetches populate fresh data when the network is back. - Mutations are not queued offline. A user who logs a result without network gets an error and is expected to retry. This is intentional — silent retry would risk double-logging if the original request succeeded but the response was lost.
- Schedule and workouts are the primary surfaces that read offline. Profile, messages, and check-in require network.
Observability
| Tool | Setup | Notes |
|---|---|---|
| Sentry | @sentry/react-native init at module top of app/_layout.tsx. Config-plugin in app.config.ts auto-uploads source maps + debug IDs during EAS build. | tracesSampleRate: 0, replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.1. sendDefaultPii: false to match the PrivacyInfo.xcprivacy declaration (NSPrivacyTracking=false). |
| PostHog | posthog-react-native initialized via useAnalyticsIdentify() once Clerk loads. EU region (https://eu.i.posthog.com). | Distinct id = Clerk user id, mirrors web. |
| Logs | console.warn/console.error only; no Pino on mobile. Sentry captures console.error automatically. | Don’t ship console.log (see CLAUDE.md). |
| Crash reports | Sentry native + JS errors + mobile replay | EAS auto-uploads dSYMs (iOS) and proguard mapping (Android). |
SENTRY_AUTH_TOKEN is required as an EAS secret env so the build can upload source maps. Org + project slugs are public (fitkit1 / fitkit-mobile).
Environment configuration
Three EAS profiles in eas.json:
| Profile | Channel | API URL | Web URL |
|---|---|---|---|
development | development | https://fitkitapi-preview.up.railway.app | (same) |
preview | preview | https://fitkitapi-preview.up.railway.app | https://app-staging.fitkit.fit |
production | production | https://api.fitkit.fit | https://app.fitkit.fit |
All EXPO_PUBLIC_* vars are inlined at build time by Metro. To change one mid-cycle, re-run eas build (or override locally in .env for expo start).
Local dev: .env with EXPO_PUBLIC_API_URL=http://localhost:3001, EXPO_PUBLIC_WEB_URL=http://localhost:3000, EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=<dev publishable key>. Then pnpm start and scan the QR with Expo Go or a development build.
Build + release
EAS handles cloud builds and OTA updates. See docs/runbooks/mobile-builds.md for the recipe.
| Command | What it does |
|---|---|
pnpm start | expo start — Metro dev server |
pnpm ios / pnpm android | expo run:<platform> — local native run |
pnpm prebuild | Regenerate ios/ + android/ from app.config.ts |
pnpm build:preview | eas build --profile preview --non-interactive |
pnpm build:production | eas build --profile production --non-interactive |
pnpm typecheck | tsc --noEmit |
pnpm lint | eslint . |
runtimeVersion: { policy: 'appVersion' } — OTA updates only apply within the same app.config.ts.version. Bump the version for a native release; ship JS-only fixes as OTA.
What’s mobile-only
Surfaces and behaviors that exist on mobile but not on web:
- QR scanner (
app/(tabs)/schedule/scan.tsx) —expo-camerabarcode scanning; web shows the QR but doesn’t scan it. - QR universal-link landing (
app/checkin.tsx) — auto-fires/sessions/:id/self-checkinon app open. - Push notifications end-to-end (registration, foreground, response routing).
- Haptics (
useHaptics()) — success/error/impact on key actions. - Offline React Query cache — web does not persist.
- Native sheets and gestures — Reanimated-driven, distinct from the web’s shadcn drawer/sheet.
What’s intentionally web-only:
- Operator dashboard (
/dashboard/*). The mobile app is member-only. Coaches and studio owners use the web dashboard. - Course content viewer (Mux player) — planned for mobile but not shipped.
- Public buy/shop landing pages.
Trade-offs vs web
| Concern | Web | Mobile |
|---|---|---|
| Auth session persistence | Clerk session cookie | expo-secure-store token cache (cookie-equivalent) |
| Data freshness | SSR for first paint, React Query for client updates | AsyncStorage-persisted cache, refetch on AppState active |
| Locale routing | URL path prefix (/he/...) | In-app state, no URL change |
| Realtime reconnect | Browser handles tab focus | App owns AppState lifecycle |
| Errors | Console + Sentry | Sentry + mobile replay + dev console.warn |
| Asset delivery | Vercel CDN, lazy-loaded | Bundled at build time (Expo asset bundling: assetBundlePatterns: ['**/*']) |
| Feature flags | PostHog client-side | PostHog React Native (same source) |
Linear issues that primarily touch mobile
- FIT-188 — Universal links + App Links setup (AASA + assetlinks.json on
app.fitkit.fit) - FIT-178 — Token-gated form signing (mobile
app/forms/sign/[token].tsx) - FIT-176 — Forms engine, mobile in-app signing surface
- Push-notification gaps and notification-template i18n — see
docs/features/push-notifications/anddocs/features/notifications/
Related docs
docs/architecture/web.md— web counterpart; mobile mirrors its data + auth model.docs/architecture/auth.md— Clerk model end to end; mobile-specific lives here.docs/architecture/i18n.md— dictionary discipline; mobile pulls from@fitkit/shared.docs/architecture/observability.md— full Sentry/PostHog story.docs/runbooks/mobile-builds.md— EAS profiles, OTA updates, App Store / Play submission.docs/features/push-notifications/— server-side fan-out + per-channel preferences.docs/features/scheduling-bookings/— check-in mechanics; mobile owns the QR scan surface.docs/features/forms/— compliance signing; mobile owns both in-app and token-gated paths.