Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureMobile architecture

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:

  1. 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.
  2. 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.
  3. Native artifacts. ios/ and android/ 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

LayerLibraryNotes
RuntimeExpo SDK 55, React Native 0.81, React 19New Architecture enabled (newArchEnabled: true)
Navigationexpo-router (file-based)Typed routes (experiments.typedRoutes: true)
Stylingnativewind 4 + Tailwind tokensdark: 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/localizationsToken cache via expo-secure-store
Data fetching@tanstack/react-query 5 + @tanstack/react-query-persist-clientAsyncStorage persister for offline tolerance
HTTPnative fetch wrapped by useApi()10s in-memory Clerk JWT cache + single 401 retry
Realtimesocket.io-clienttransports: ['websocket'], AppState-aware reconnect
Camera / barcodeexpo-cameraQR scan for check-in
Pushexpo-notificationsExpo push tokens registered with API on sign-in
Storage@react-native-async-storage/async-storage, expo-secure-storeAsyncStorage = cache; SecureStore = secrets
Crash + replay@sentry/react-nativeMobile replay enabled; 0 traces sample, 1.0 replay-on-error
Analyticsposthog-react-nativeEU region by default
File I/Oexpo-file-system, expo-image-manipulator, expo-image-pickerForm attachments, progress photos
Localizationexpo-localization + dictionaries from @fitkit/sharedHebrew (RTL) is default, see “i18n” below
Hapticsexpo-hapticsWrapped in useHaptics()
Status barexpo-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-shared on GitHub Packages.
  • Aliased in mobile as: @fitkit/shared (via npm: protocol in package.json), so imports read the same as in this repo.
  • Auto-publish: .github/workflows/publish-shared.yml patches on every main push that touches libs/shared/**. Manual minor/major bumps via workflow_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 wrapper

Routes

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.tsx

Route 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:

  1. Clerk not loaded → spinner
  2. !isSignedIn/(auth)/sign-in
  3. /users/me OR /legal/consents/status still loading → spinner
  4. needsLegalConsent/onboarding/accept-terms
  5. isProfileIncomplete/onboarding/complete-profile
  6. 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:

ProviderPurposeSource
ClerkProviderIdentity. Token cache wired to expo-secure-store so sessions survive cold start.@clerk/clerk-expo
GestureHandlerRootViewRequired root for react-native-gesture-handlerreact-native-gesture-handler
SafeAreaProviderSafe-area insets for notches, dynamic island, gesture navreact-native-safe-area-context
KeyboardProviderKeyboard avoidance + animated scrollreact-native-keyboard-controller
I18nProviderLocale + RTL direction, dictionary tsrc/providers/i18n-provider.tsx
QueryProviderReact Query client + AsyncStorage persister + AppState-driven refetchsrc/providers/query-provider.tsx
ThemeProviderColor scheme propagationsrc/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.
  • AppState listener triggers refetch on transition to active — closest mobile-equivalent of refetchOnWindowFocus.

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:

  1. Token cache via expo-secure-store. Without this, Clerk drops sessions on every cold start. src/lib/secure-token-cache.ts implements TokenCache with getToken/saveToken. Corrupted entries are deleted and re-issued silently.
  2. Sign-out drops the device push token. Profile → Sign out calls revokeCurrentDeviceToken() before signOut() 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 catches pathPrefix: '/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. Second 401signOut() + router.replace('/(auth)/sign-in').
  • On non-2xx: parse { message } body if present, throw Error(message).
  • Sets X-Locale: <lang> on every request — the API uses it for locale-aware copy in notification payloads.
  • Base URL from expo-constants extra.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.
  • AppState listener 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:

  1. Wait for Clerk session.
  2. If Constants.expoConfig.extra.eas.projectId is missing → warn + skip (dev builds without EAS env vars).
  3. Request permission (expo-notifications). iOS prompts; Android grants by default.
  4. Fetch the device’s Expo push token via getExpoPushTokenAsync({ projectId }).
  5. On Android, create the default channel with vibration pattern + brand light color.
  6. POST /devices/register with { token, platform } (auth: Clerk bearer). The API stores it on the user row and sends through Expo’s push service.
  7. Foreground handler: banner + list + sound, no badge.
  8. Response listener: data.routerouter.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.

SourceDestinationAuth model
QR code at gym (in-app scan)app/(tabs)/schedule/scan.tsxPOST /sessions/:id/self-checkinBearer
QR code captured outside the app (Messages, email, web fallback)Universal link → app/checkin.tsxBearer
Push notification tapdata.routerouter.push(route)Bearer
Form-signing link from WhatsApp/SMSapp/forms/sign/[token].tsxToken-only (no auth)
Clerk invitation redirectapp/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.

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, /ru locale 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 flip

A locale flip that changes direction calls Updates.reloadAsync() so the whole tree remounts with the new direction. A flip that preserves direction (e.g. enru) 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. PersistQueryClientProvider writes 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

ToolSetupNotes
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).
PostHogposthog-react-native initialized via useAnalyticsIdentify() once Clerk loads. EU region (https://eu.i.posthog.com).Distinct id = Clerk user id, mirrors web.
Logsconsole.warn/console.error only; no Pino on mobile. Sentry captures console.error automatically.Don’t ship console.log (see CLAUDE.md).
Crash reportsSentry native + JS errors + mobile replayEAS 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:

ProfileChannelAPI URLWeb URL
developmentdevelopmenthttps://fitkitapi-preview.up.railway.app(same)
previewpreviewhttps://fitkitapi-preview.up.railway.apphttps://app-staging.fitkit.fit
productionproductionhttps://api.fitkit.fithttps://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.

CommandWhat it does
pnpm startexpo start — Metro dev server
pnpm ios / pnpm androidexpo run:<platform> — local native run
pnpm prebuildRegenerate ios/ + android/ from app.config.ts
pnpm build:previeweas build --profile preview --non-interactive
pnpm build:productioneas build --profile production --non-interactive
pnpm typechecktsc --noEmit
pnpm linteslint .

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-camera barcode scanning; web shows the QR but doesn’t scan it.
  • QR universal-link landing (app/checkin.tsx) — auto-fires /sessions/:id/self-checkin on 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

ConcernWebMobile
Auth session persistenceClerk session cookieexpo-secure-store token cache (cookie-equivalent)
Data freshnessSSR for first paint, React Query for client updatesAsyncStorage-persisted cache, refetch on AppState active
Locale routingURL path prefix (/he/...)In-app state, no URL change
Realtime reconnectBrowser handles tab focusApp owns AppState lifecycle
ErrorsConsole + SentrySentry + mobile replay + dev console.warn
Asset deliveryVercel CDN, lazy-loadedBundled at build time (Expo asset bundling: assetBundlePatterns: ['**/*'])
Feature flagsPostHog client-sidePostHog 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/ and docs/features/notifications/
  • 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.