Expo SDK 55
React Native 0.84, New Architecture, Expo Router, NativeWind 5
The OpenInsure mobile app gives policyholders native access to their coverage, claims, payments, and documents on iOS and Android. Built with Expo SDK 55 and React Native’s New Architecture.
Expo SDK 55
React Native 0.84, New Architecture, Expo Router, NativeWind 5
OTP Auth
2-step OTP login + biometric (Face ID / Touch ID) + SecureStore JWT
Offline First
useOfflineQuery caches API responses (7-day TTL) + PDF cache (30-day)
EAS Build
Fingerprint → OTA update or native build + store submit
apps/mobile/├── app/│ ├── _layout.tsx # Root: SplashScreen, AuthGate, providers│ ├── login.tsx # 2-step OTP login (policy number → 6-digit code)│ ├── auth/verify.tsx # Magic link deep-link handler│ └── (tabs)/│ ├── _layout.tsx # NativeTabs (5 tabs)│ ├── (home,claims)/│ │ ├── home.tsx # Dashboard: KPIs, quick actions, recent claims│ │ ├── claims.tsx # Claims list (FlatList, offline cached)│ │ ├── file-claim.tsx # FNOL form (Zod validated)│ │ ├── i/[id].tsx # Claim detail + error boundary│ │ ├── support.tsx # AI chat support│ │ └── members/ # Policy member management (admin)│ ├── (policy)/│ │ ├── index.tsx # Policy detail + coverages + endorsements│ │ ├── id-card.tsx # Digital ID card with QR code│ │ ├── renewal.tsx # Renewal intent form│ │ ├── request-coi.tsx # COI request (Zod validated)│ │ └── request-endorsement.tsx # Endorsement request (Zod validated)│ ├── (documents)/│ │ └── index.tsx # Document vault (authenticated download)│ ├── (payments)/│ │ └── index.tsx # Invoices + Stripe payment initiation│ └── (settings)/│ ├── index.tsx # Account, biometric, notifications, sign-out│ └── profile.tsx # Edit profile (Zod validated)├── components/│ └── ui/ # NativeWind primitives: Button, Input, Card, etc.├── lib/│ ├── api-provider.tsx # MobileApiProvider: QueryClient + token injection│ ├── store/auth-mobile.ts # TanStack Store: OTP auth + biometric + SecureStore│ ├── use-offline-query.ts # Cache wrapper for TanStack Query│ ├── use-reduced-motion.ts # Accessibility: respect system reduce-motion│ ├── validation.ts # Zod v4 schemas (FNOL, COI, endorsement, profile, member)│ ├── offline-cache.ts # AsyncStorage (JSON) + FileSystem (PDF) caching│ ├── document-download.ts # PDF download → share sheet│ ├── notifications.ts # Push token registration│ ├── sentry.ts # Error tracking (PII-filtered)│ └── brand.ts # White-label config├── test/│ ├── setup.ts # Vitest mocks for RN modules│ └── mocks/factories.ts # Realistic insurance mock data└── eas.json # Build profiles + App Store submit configEAS project: 2c00cf79-cdc7-40e0-a4bd-6e4866336b22
Account / slug: mhcis / openinsure
Bundle IDs: dev.openinsure.mobile (iOS + Android)
Apple Team ID: 2D65GH64H3 | ASC App ID: 6761020503
Auth state lives in a TanStack Store at lib/store/auth-mobile.ts. There is no React Context — all screens subscribe directly with individual selectors.
const user = useAuthStore((s) => s.user);const requestOtp = useAuthStore((s) => s.requestOtp);const verifyOtp = useAuthStore((s) => s.verifyOtp);POST /auth/policyholder-otp-request with the policy number 3. API sends a 6-digit OTP to the email
on file (10-minute expiry) 4. User enters the OTP code 5. App calls POST /auth/policyholder-token with policy number + OTP 6. API returns a JWT (8-hour expiry) — stored
in SecureStore (Keychain) 7. Push notification token is registered (best-effort)On app launch, app/_layout.tsx blocks routing behind a biometricPending gate. If biometric is enabled and the device supports it, a Face ID / Touch ID prompt fires before any screen renders.
Failed biometric clears the session and forces re-login via OTP.
clearSession() clears: SecureStore JWT, biometric flag, offline caches (AsyncStorage + FileSystem PDFs), and the TanStack Query cache via the API provider’s token-change listener. No PII persists on device after logout.
The mobile app calls the API directly with a Bearer JWT — no Next.js proxy layer.
| Mobile Screen | API Endpoint | Method |
|---|---|---|
| Home (policy) | /v1/portal/policies/:id | GET |
| Home (claims) | /v1/claims?orgId=...&policyId=... | GET |
| Home (invoices) | /v1/portal/invoices | GET |
| Claims list | /v1/claims | GET |
| File claim | /v1/claims/fnol | POST |
| Policy detail | /v1/portal/policies/:id | GET |
| Coverages | /v1/policies/:id/jacket/coverages | GET |
| Endorsements | /v1/policies/:id/endorsements | GET |
| Request COI | /v1/coi/generate | POST |
| Request endorsement | /v1/policies/:id/endorsements | POST |
| Documents | /v1/portal/documents | GET |
| Document download | /v1/portal/documents/:id/download | GET |
| Invoices | /v1/portal/invoices | GET |
| Pay invoice | /v1/invoices/:id/payment-intent | POST |
| Profile | /v1/portal/profile | GET / PUT |
| Push token | /v1/portal/push-tokens | POST |
| Support chat | /v1/chat | POST |
| Renewal intent | /v1/policies/renewal/intent | POST |
The MobileApiProvider creates a fetcher that prepends the API base URL and injects the Bearer token from the auth store. 401 responses auto-clear the session.
lib/use-offline-query.ts wraps useApiQuery with offline cache fallback:
setCache(path, data)isStale: trueUsed on: Home dashboard, policy detail, and claims list.
| Cache | TTL | Storage |
|---|---|---|
| API JSON responses | 7 days | AsyncStorage |
| PDF documents | 30 days | FileSystem |
pruneExpired() runs on app start to clean up stale entries.
All 6 form screens use Zod v4 schemas from lib/validation.ts:
| Form | Schema | Fields Validated |
|---|---|---|
| File Claim | fnolClaimSchema | lossDate, lossDescription (min 10 chars), estimatedLoss (optional, positive) |
| Request COI | coiRequestSchema | holderName, holderAddress (required) |
| Request Endorsement | endorsementRequestSchema | changeType, description (min 10 chars) |
| Edit Profile | profileSchema | email, phone, ZIP (format validated) |
| Add Member | memberSchema | entityName, email, EIN (XX-XXXXXXX), memberType |
The <Input error={}> component displays inline validation errors with red border + helper text.
42 tests across 3 test files, run with Vitest:
pnpm --filter @openinsure/mobile test| Test File | Tests | Coverage |
|---|---|---|
lib/__tests__/validation.test.ts | 15 | All 5 Zod schemas, edge cases |
lib/__tests__/offline-cache.test.ts | 8 | TTL expiry, pruning, JSON parse errors |
lib/store/__tests__/auth-mobile.test.ts | 19 | OTP request/verify, init, logout, biometric, 401 |
useReducedMotion() hook gates Reanimated entry animations on 3 key screensaccessibilityRole="button"), Input (accessibilityLabel, accessibilityState), ListItem (accessibilityLabel, accessibilityHint)The app uses phosphor-react-native@^3.0.3 (NOT @phosphor-icons/react — that’s for web). Tab bar icons use Expo’s NativeTabs.Trigger.Icon sf="..." SF Symbols.
import { Bell, FileText, CreditCard } from 'phosphor-react-native';Workflows live in apps/mobile/.eas/workflows/.
production.yml)Triggered by CircleCI deploy-mobile job on merge to master.
fingerprint → get-build (check existing) ├─ JS-only change → OTA update to production channel (minutes) └─ Native change → full build + App Store / Play Store submit (hours)preview.yml)Delivers OTA update to preview channel — testers get changes instantly.
development.yml)Manual trigger. Builds dev client for local debugging with expo start --dev-client.
The deploy-mobile job in .circleci/config.yml:
EXPO_CIRCLECI_ACCESS_TOKEN → EXPO_TOKENeas workflow:run .eas/workflows/production.ymlcd apps/mobile
# Start Metro bundlernpx expo start
# Run on iOS simulatornpx expo run:ios
# Run on Android emulatornpx expo run:android
# Point at local API workerEXPO_PUBLIC_API_URL=http://localhost:8787 npx expo start
# Run testspnpm testcd apps/mobile
# iOS production build (interactive first time for Apple credentials)eas build --platform ios --profile production
# Android production buildeas build --platform android --profile production
# Submit to storeseas submit --platform ios --profile productioneas submit --platform android --profile production