Mobile
Mobile — ui/ios (Expo)
The mobile application uses the Expo managed workflow with React Native and TypeScript.
Core Stack
| Aspect | Choice |
|---|---|
| Framework | Expo SDK (latest stable) |
| Navigation | expo-router |
| Language | TypeScript (strict mode) |
| Auth | Better Auth (Expo client → the applications-UI server) |
| Testing | Vitest (React Native environment) + @testing-library/react-native |
| Coverage | ≥ 80% |
Auth — Better Auth
The mobile app authenticates against the same Better Auth instance the web applications use — hosted by the applications-UI server (see Frontend), backed by Neon. Use the Better Auth Expo client plugin; a login screen is scaffolded at app/(auth)/login.tsx via expo-router. The app never talks to Neon directly — it calls the FastAPI API with the Better Auth session token.
No PHI is cached on-device without review. Tokens live in secure storage (expo-secure-store).
Environment Configuration
Environment variables are provided via app.config.ts reading EXPO_PUBLIC_* vars:
export default {
expo: {
extra: {
apiUrl: process.env.EXPO_PUBLIC_API_URL,
betterAuthUrl: process.env.EXPO_PUBLIC_BETTER_AUTH_URL,
},
},
};Dependencies
expo— Expo SDKexpo-router— File-based routingbetter-auth+@better-auth/expo— auth clientexpo-secure-store— secure token storageposthog-react-native— product analytics + session replay (masking on — see below)
Dev
vitest+@testing-library/react-native— component testing (React Native environment)
Analytics — PostHog (posthog-react-native)
The mobile app uses posthog-react-native for product analytics, feature flags, experiments, and session replay, feeding the same PostHog project as the web app. PostHog's full role across the stack is described in Observability.
The same masking discipline applies — replay can capture clinical screens, so:
- Enable session-replay masking:
maskAllTextInputs: trueandmaskAllImages: true, plus wrap PHI-bearing views in<PostHogMaskView>(or theph-no-capturemarker). - Identify by opaque user IDs only — never a name, email, or MRN. Keep no PHI in event names, property keys, or flag keys. See HIPAA Compliance.
// app/_layout.tsx — wrap the router in the PostHog provider
import { PostHogProvider } from 'posthog-react-native'
export default function RootLayout() {
return (
<PostHogProvider
apiKey={process.env.EXPO_PUBLIC_POSTHOG_KEY!}
options={{ host: process.env.EXPO_PUBLIC_POSTHOG_HOST }}
autocapture={{
captureScreens: true,
// Session replay masks PHI by default on-device.
sessionReplay: { maskAllTextInputs: true, maskAllImages: true },
}}
>
{/* expo-router <Slot /> / <Stack /> */}
</PostHogProvider>
)
}Testing
Tests run on Vitest with @testing-library/react-native, enforcing ≥ 80% coverage via Vitest's v8 provider.
Because React Native ships no DOM, Vitest needs an RN-aware setup rather than the old jest-expo preset: alias react-native to react-native-web (or an RN-compatible resolver) so components resolve under Node, register @testing-library/react-native in a setup file, and run in a jsdom environment. Example vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
// Resolve the RN entrypoint under Node — RN's native modules can't load in Vitest.
resolve: {
alias: {
'react-native': 'react-native-web',
},
},
test: {
// jsdom gives react-native-web a DOM to render into.
environment: 'jsdom',
// Registers @testing-library/react-native matchers + cleanup and RN globals.
setupFiles: ['./vitest.setup.ts'],
globals: true,
coverage: {
provider: 'v8',
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
})The setup file registers the RN matchers and auto-cleanup so every test file gets them without repeating boilerplate:
// vitest.setup.ts
import '@testing-library/react-native/extend-expect' // toBeVisible(), toHaveTextContent(), …
import { cleanup } from '@testing-library/react-native'
import { afterEach, vi } from 'vitest'
// Unmount the rendered tree after each test to avoid cross-test state leaks.
afterEach(() => cleanup())
// Native-only modules aren't available under Node — stub the ones tests touch.
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}))