NestleAI
AI-Powered Parenting Companion · iOS & Android
A production React Native (Expo) app: an AI 'co-parent' for new parents. Offline-first baby-care tracking wrapped around a personality-driven AI companion — shipped to both app stores. Built end-to-end as the founding engineer.
Tech Stack
What I Owned
- ·End-to-end mobile app (iOS + Android)
- ·AI chat UX & personality system
- ·Offline-first sync engine
- ·App Store & Play Store releases
Engineering Focus
- ·Offline-first SQLite + durable sync queue
- ·New Architecture (Fabric) for 60fps UI
- ·Backend-proxied AI with on-device context
- ·File-based routing across 100+ screens
Security
- ·Secrets at rest in Keychain / Keystore
- ·No model API keys on device (backend proxy)
- ·Backend-authoritative entitlements
- ·Privacy-conscious permission model
Performance
A production React Native (Expo) app: an AI "co-parent" for new parents. Offline-first baby-care tracking (sleep, feeding, diapers, growth, memories) wrapped around a personality-driven AI companion, monetized with subscriptions and shipped to both app stores.
Stack at a glance: Expo SDK 54 · React Native 0.81.5 (New Architecture / Fabric) · React 19 · Hermes · TypeScript (strict) · Expo Router · Zustand · TanStack Query · SQLite · Clerk · RevenueCat · PostHog · Sentry · NativeWind · EAS Build + OTA Updates
1. Executive summary
NestleAI is a consumer mobile app for new parents built as a single Expo/React Native codebase targeting iOS and Android. The product thesis is emotional: instead of yet another logging utility, it positions an AI "co-parent" — a personality-matched companion that greets you, notices patterns in your baby's data, and answers parenting questions in context.
The engineering is organized around four hard problems, and the architecture is essentially the set of answers to them:
| Hard problem | Architectural answer |
|---|---|
| Parents log data at 3 AM with no signal | Offline-first: SQLite + a durable sync queue with exponential backoff |
| AI must feel personal and safe | Backend-proxied AI with per-request baby context injection; no model keys on device |
| One codebase, two platforms, premium feel | New Architecture + Reanimated v4 + FlashList, platform-split screens, a strict design system |
| Ship fixes fast, monetize, measure | EAS OTA updates, RevenueCat entitlements, PostHog + Sentry observability |
The codebase is layered and feature-sliced: app/ (routes) → screens/ (screen
implementations) → components/ (feature UI) over a shared lib/ services layer, with ~30
domain-scoped Zustand stores and a clean three-tier persistence strategy (SecureStore / SQLite /
AsyncStorage).
2. Technology stack & why
| Concern | Choice | Why this over alternatives |
|---|---|---|
| Framework | Expo SDK 54 + RN 0.81.5 | Managed-but-ejectable: native ios/+android/ projects are committed, but Expo's module ecosystem, EAS, and OTA updates are retained. Best of both worlds. |
| Runtime | New Architecture (Fabric/TurboModules/JSI) + Hermes | Native-thread rendering and zero-copy JS↔native calls; unlocks 60fps animations. Hermes ships bytecode for faster cold start. |
| Routing | Expo Router 6 (file-based) + typedRoutes | Convention-over-config routing with compile-time-typed navigation; deep links and route groups for free. |
| Server state | TanStack Query 5 | Caching, dedup, background refetch, retry policy — without hand-rolling a fetch cache. |
| Client state | Zustand 5 | Hook-based, minimal boilerplate, fine-grained subscriptions, first-class persistence middleware. No Redux ceremony, no Context re-render storms. |
| Local DB | expo-sqlite | Relational queries, transactions, indexes for thousands of log entries — AsyncStorage can't do this. |
| Auth | Clerk (@clerk/clerk-expo) | Managed OAuth (Apple/Google), 2FA/TOTP, secure token cache, session lifecycle. Auth is a liability you don't want to own. |
| Payments | RevenueCat | Cross-platform IAP/subscription abstraction with server-side receipt validation + webhooks. |
| Styling | NativeWind 4 (Tailwind → RN StyleSheet) | Utility-first styling compiled at build time (zero runtime CSS parser), enforced by a written design system. |
| Forms/validation | react-hook-form + Zod 4 | Uncontrolled-input performance + schema validation shared between forms and API payloads. |
| Lists | @shopify/flash-list 2 | Virtualized recycling for long logs/galleries — FlatList jank eliminated. |
| Animation | Reanimated 4 + Worklets | Animations driven on the UI thread, immune to JS-thread stalls. |
| Analytics | PostHog (+ session replay) | Product funnels, retention, replay — self-serve and event-typed. |
| Errors/perf | Sentry | Crash reporting, tracing, release health, source-map upload via Metro plugin. |
| AI | Backend proxy (/api/ai/*) | Keeps model keys server-side; lets the provider/model change without an app release. |
| Media/video | Cloudflare R2 + Worker gateway, Shotstack | Cheap object storage behind a Worker; Shotstack renders AI memory videos server-side. |
| CI/Release | EAS Build + EAS Update | Cloud builds, channels, and OTA JS delivery. |
| Testing | Jest + Testing Library + Maestro | Unit/logic in Jest, full user journeys in Maestro E2E. |
3. System architecture (the big picture)
Layered structure
The repo separates routing, screens, feature components, and shared services — each layer depends only on the ones below it.
app/ file-based routes (thin) — delegate to screens, define navigation/layout
├─ (auth)/ auth route group
├─ (co-parent-onboarding)/ emotional onboarding group
├─ (tabs)/ main tab navigator
└─ <feature>/ modal & detail routes (food, sleep-v2, journal-v2, …)
│
screens/ screen implementations, often platform-split (.ios.tsx / .android.tsx),
└─ <feature>/{components,hooks,utils,types} co-located by feature
│
components/ reusable + feature UI (chat, sleep, food, journeys-v2, ui primitives, …)
│
lib/ + src/ + store/ + hooks/ shared services
├─ lib/api/ axios clients, AI client, React Query hooks, storage gateway
├─ lib/auth/ Clerk token manager, session guard
├─ lib/sync/ offline queue + sync worker
├─ lib/database.ts SQLite DAOs; db/init.ts schema + migrations
├─ lib/subscriptions, lib/entitlements monetization
├─ lib/analytics PostHog + Sentry
├─ store/ ~30 Zustand domain stores
└─ src/config/environment.ts single config source of truth
Why this separation: routes change for navigation reasons, screens change for UX reasons, components change for design reasons, and services change for business-logic reasons. Keeping them in distinct layers means a change in one rarely ripples into the others, and the same feature component can be composed across multiple screens.
Runtime data flow
The flow is local-first, with the network treated as an eventual-consistency target rather than a dependency:
- Screens and components read and write through Zustand stores for hot UI state, and read server-owned data through TanStack Query.
- Domain writes land locally first in SQLite (instant UI feedback), and durable side-effects (media uploads, backups) are enqueued into the sync queue.
- The sync worker drains that queue whenever the NetworkProvider reports real connectivity, talking to the backend API and the storage gateway over HTTPS with a Clerk JWT.
- AI requests go through
lib/api/aiClienttoapi.nestleai.app/api/ai/*, which owns the LLM provider; media lands in Cloudflare R2 behind the Worker gateway.
Three storage tiers, each with a clear job:
expo-secure-store(Keychain/Keystore) → auth tokens & secrets-at-rest- SQLite → structured user data (profiles, journals, logs, chat history)
- AsyncStorage → key/value app state & Zustand persistence (settings, sync queue, prefs)
4. Navigation & app shell
File-based routing with route groups
Expo Router maps the app/ directory to the navigation tree. The app uses route groups
(parenthesized folders that don't appear in the URL) to model three distinct "modes":
app/(auth)/—sign-in,sign-up,forgot-password. The group layout redirects away to home once a user is authenticated.app/(co-parent-onboarding)/—welcome → questions → auth-wall → personality-reveal → personality-picker. Gestures are disabled to prevent backtracking through the emotional flow.app/(tabs)/— the main 5-tab navigator (chat, today, baby, journal, nest) plus a hiddenjourneysroute. Custom 80px tab bar with a mint glow on the focused icon.
Beyond the tabs, the root stack defines 100+ screens with deliberately varied presentations:
slide_from_bottom modals for create/log flows (food/log-feeding, sleep-v2/log-sleep,
journal/new-entry), transparentModal for overlays, and a fullScreenModal for emergency
mode (gestures disabled). Dynamic routes (baby/[babyId], food/[foodId], notes/[id])
handle entity detail pages.
typedRoutes: true (in app.config.ts) generates compile-time types for every route, so
router.push() is autocompleted and typo-proof. Deep linking is wired via the nestleai://
scheme and iOS associatedDomains (applinks:nestleai.app).
Provider hierarchy & bootstrap order
app/_layout.tsx composes the entire app shell as a deliberately ordered provider stack —
order matters because later providers depend on earlier ones:
GestureHandlerRootView → KeyboardProvider → NetworkProvider →
BottomSheetModalProvider → PostHogProvider → AnalyticsInitializer →
QueryClientProvider → ClerkProvider → AnalyticsAuthWrapper →
EntitlementsAuthWrapper → EmergencyModeProvider → ClerkLoaded →
Stack (navigation)
+ headless services: SessionExpiryGuard, PersonalityInitializer,
NotificationListener/Handler, PushTokenSync, InvitationPoller,
SubscriptionInitializer, SubscriptionExpiryHandler, ShakeListener,
NetworkStatusBanner
Module-load-time setup runs before React mounts, in a specific order to avoid crashes:
RevenueCat log handler first (prevents an Android crash), then Sentry.init, then the
notification handler, then the QueryClient, then Clerk's token cache. Fonts (Inter + Outfit)
are preloaded with useFonts and the splash screen is held until they resolve to avoid a
font-flash (FOUT). app/index.tsx acts as a routing gate: it briefly shows a "welcome back"
screen, hydrates profile/settings, then routes to onboarding, auth, or the chat tab.
A notable pattern: the "headless services" (e.g. NotificationHandler, PushTokenSync,
SubscriptionInitializer) are render-null components mounted under the navigator. This is a
clean way to attach app-wide lifecycle side effects to the React tree without polluting screens.
5. Feature domains
The app is broad. Each domain typically spans app/<feature>/ routes, a screens/<feature>/
implementation, components/<feature>/ UI, and a dedicated Zustand store.
| Domain | What it does |
|---|---|
| Chat | The AI co-parent conversation; streaming replies, quick prompts, saved/favorite messages, voice input |
| Today | Daily dashboard: AI greeting card, proactive follow-ups, reminders, quick-add |
| My Baby | Multi-baby profiles, growth tracking, medical card, care team & caregiver invitations |
| Journal / Journal-v2 | Photo/memory albums; v2 adds day/week views, archive, export |
| Nest | Discover hub — parenting resources, articles, expert/video content |
| Sleep / Sleep-v2 | Sleep logging → v2 adds plans, a setup wizard, trends, and a "sleep whisperer" coach |
| Food | Feeding logs (breast/formula/solids), food introduction tracking, meal/week plans, grocery lists, milk trends |
| Diapers | Diaper logging + analytics + export |
| Memory Stories | AI-generated cinematic video reels from selected memories (rendered via Shotstack) |
| Journeys / Journeys-v2 | Guided multi-step experiences (sleep setup, feeding intro) with progress + premium gating |
| Reminders / Schedule | Local-notification-backed reminders and a calendar |
| Notes / Documents | Quick notes (search/stats) and document storage (medical records) |
| Emergency mode | A full-screen safety gate with follow-up flows |
| Support / Feedback | In-app help assistant, bug reports, feature requests (shake-to-report) |
The "v2" pattern is itself an architectural signal. sleep-v2, journal-v2, and
journeys-v2 exist alongside their v1 counterparts rather than replacing them in place. This
is a pragmatic strangler-fig approach: ship redesigned experiences behind new routes, migrate
traffic, and retire the old ones once stable — without a risky big-bang rewrite. The same
pragmatism shows in the dual profile stores (multiBabyProfileStore for the modern
backend-integrated path, profileStore for the legacy SQLite path) loaded together at startup.
6. Data layer & offline-first architecture
This is the architectural centerpiece. A parent logging a 3 AM feed in a basement nursery has no signal — and losing that data is unacceptable. So the app is local-first: every write lands in SQLite immediately and syncs opportunistically.
SQLite as the source of truth on-device
db/init.ts defines and migrates the schema; lib/database.ts is the DAO/repository layer. Key
tables include baby_profiles, journal_entries, today_data, albums, parent_profiles,
conversation_memories, chat_sessions, chat_messages, milestones, monthly_recaps,
memory_stories, reminders, and offline_actions (the sync queue).
Design choices that matter:
- Singleton, promise-guarded initialization prevents init races on cold start.
- Idempotent migrations run on every launch via non-destructive
ALTER TABLEs. - Parameterized queries throughout (SQL-injection safe).
PRAGMA foreign_keys = ONwith cascade deletes for referential integrity.- ~30 indexes on
user_id/baby_profile_id/created_atbecause a typical parent accumulates hundreds of entries. - Array/complex fields serialized as JSON strings, deserialized on read.
resetDatabase()on logout clears all user data — important on shared devices.
The DAO layer exposes service objects (journalService, todayService, chatMessageService,
reminderService, …) each with create/getById/getByUserId/update/delete, returning typed
objects. Screens never touch SQL directly.
The sync engine
The sync layer (lib/sync/) is a durable outbox + worker pattern. A single user action flows
through it like this:
- Write locally first. The user action writes to SQLite and the UI updates immediately — no spinner, no waiting on the network.
- Enqueue the durable side-effect. A task (media upload, backup) is pushed onto the sync queue, which lives in a Zustand store backed by AsyncStorage so it survives app restarts.
- Wait for real connectivity. When
NetworkProviderreports the device is back online, it nudges the worker. - Drain in batches. The worker pulls pending tasks, marks each
syncing, and uploads to the backend / storage gateway. - Resolve. Success marks the task
completedand stores the returned cloud key. A5xx/timeout/429retries with backoff; a4xxfails permanently (no pointless retries).
Implementation details worth calling out:
- Real connectivity checks via
expo-network: a task only runs when the device is both connected and the internet is actually reachable — not merely "has a network interface." - Exponential backoff with a fixed ladder (
5s → 15s → 30s → 1m → 5m, max 5 attempts). - Error classification: network/transient errors (
ECONNREFUSED,ETIMEDOUT,5xx, plus408/429) are retried; client errors (4xxlike400/401/403/404) fail fast without retry. - Crash recovery: on startup the worker resets any task stuck in
syncing(interrupted by a prior crash) back to pending. - Reconnect trigger: a 10-second connectivity poll fires
syncAll()on the offline→online edge, andNetworkProvidersurfaces a "back online" banner. - Queue persistence: the queue lives in a Zustand store backed by AsyncStorage, so it survives app restarts.
The queue is intentionally a last-write-wins / server-authoritative model rather than full CRDT conflict resolution — the right call for single-user-per-baby logging, where the complexity of conflict-free replication wouldn't pay for itself.
Server state with TanStack Query
For data that is server-owned (baby teams, invitations, entitlements, resources), TanStack Query handles caching and invalidation:
- Custom retry policy (
react-query.lib.ts): never retry on401(the token is stale — refresh it, don't replay), otherwise up to 2 attempts. - 5-minute
staleTimedefault: switching tabs and returning within 5 minutes serves cached data instead of refetching — meaningful on metered/spotty mobile connections. - Hierarchical query keys (
["babies","detail",id,"members"]) enable surgical cache invalidation after mutations, with toast feedback on success/error.
Two axios instances separate concerns: apiClient (main backend) and storageClient (the
Cloudflare Worker storage gateway), both with 30s timeouts for large media.
Premium backup
lib/backup/BackupService.ts is a singleton that serializes the full local dataset
(babies, memories, reminders, settings) into a versioned BackupPayload and ships it to the
storage gateway. It's network-aware, change-tracked, and triggered on multiple signals
(manual, auto, network_recovered, app_backgrounded). Free users keep images locally
(AsyncStorage, keyed by user+memory); premium users get cloud backup — a clean, simple paywall
boundary.
7. The AI subsystem
Backend-proxied, never client-side
Although openai appears in package.json, the client never imports it — verified by
grep. Every AI feature is a fetch to the app's own backend (config.apiBaseUrl + /api/ai/*)
authenticated with a Clerk JWT. The model/provider, system prompts, and API keys all live
server-side.
App (thin client) ──Clerk JWT + baby context──▶ api.nestleai.app /api/ai/* ──▶ LLM provider
This is the single most important AI decision in the project, and it's the right one:
- No model API keys ship in the binary — they can't be extracted and abused.
- The model can change (provider, version, prompt strategy) without an app release.
- Server-side prompt control keeps safety/guardrails out of the client's reach.
- Usage metering & rate limiting happen where they can be enforced.
lib/api/aiClient.ts is the typed client over a rich endpoint surface: /api/ai/chat,
/story, /categorize, /quick-prompt, /greeting, /transcribe, /personality-intro,
/context-summary, /today-summary, /weekly-highlights, and a /chat/first-welcome
sequence. Chat and story support SSE streaming with line-buffered delta parsing for
token-by-token UI updates.
Online/offline chat split
hooks/chat/ is divided into online/ and offline/:
- Offline (
useOfflineSession) reads cached sessions/messages straight from SQLite — chat history is always viewable with no network. - Online (
useOnlineSender) posts to the backend, which owns session creation and persistence, then reconciles optimistic temp message IDs with the real UUIDs returned.
Messages carry a sync_status (pending → synced), so the UI can render instantly and
reconcile later — the same optimistic pattern as the data layer.
Context injection (personalization)
lib/api/aiContextBuilder.ts assembles per-request context from on-device data: baby name, age
group, gender; the last ~15 chat messages (chronological, system/saved filtered); and a
≤500-char rolling ai_context_summary of recent conversations. The backend receives this as a
structured context object alongside the message array and the selected personalityId, so
replies are grounded in this baby and this history.
The "AI co-parent" — personality system
The product's signature feature (spec'd in AI_COPARENT_IMPLEMENTATION.md):
- Emotion-before-auth onboarding. The flow opens with feeling, not a login wall: a welcome screen and 7–9 reflective questions, with the auth gate deferred until after question ~4 — by which point the user is invested. Answers persist (Zustand + AsyncStorage) and submit once authenticated. Copy reframes signup as "Save your AI co-parent."
- Personality catalog. Free personas — Gentle Coach, Mindful Partner, Hype Friend, Science Mode — plus a premium Nurturing Therapist. The backend recommends a persona from the onboarding answers, then reveals it with a sample greeting; premium recommendations always show a free alternative side-by-side (no bait-and-switch).
- App-open context.
useAppOpenContextfetches a contextual greeting + top follow-up on launch/resume (30-min cache, fire-and-forget). Greetings carry an intent (welcome_back,check_in,encouragement,milestone, …) that drives the gradient and copy of the Today-screen greeting card, and follow-up cards track ashown → engaged/dismissedlifecycle for analytics. - Decoupled propagation. Personality changes broadcast over an
eventemitter3bus (PERSONALITY_CHANGED) so chat, the Today card, and recommendations react without tight coupling (see §11).
AI-generated memory videos
lib/memoryStories/ orchestrates: the backend generates a narrative/captions from selected
memories, then Shotstack renders a vertical (1080×1920) video with images, captions, and a
mood soundtrack. The app just orchestrates the flow and tracks render jobs — heavy rendering is
offloaded server-side.
8. Authentication, subscriptions & entitlements
Auth (Clerk)
- Methods: email/password (+ TOTP 2FA), Apple (iOS) and Google OAuth via
expo-auth-session/expo-web-browser, password reset by email code. - Token storage: Clerk's token cache is backed by
expo-secure-store(iOS Keychain / Android Keystore) — tokens are never in plaintext storage. - Centralized token access:
lib/auth/tokenManager.tsexposesgetAuthToken()(using a dedicatednestleai-backendJWT template) and afetchWithAuth()wrapper that auto-retries once with a fresh token on401— handling the common "stale token after long background" case. - Route protection: the
(auth)group redirects authenticated users home;SessionExpiryGuardcatches sessions that expire while backgrounded and only redirects on a genuine signed-in→signed-out transition (avoiding false logouts on cold start).
Why Clerk: auth is high-risk, high-maintenance, and undifferentiated. Outsourcing OAuth, 2FA, session refresh, and secure caching to a specialist is the mature call for a solo/small team.
Subscriptions (RevenueCat)
Configured at launch with platform keys, then Purchases.logIn(user.id) ties the RevenueCat
identity to the Clerk user once auth loads. A single entitlement ("NestleAI Premium") gates
all paid features; offerings drive the native paywall (react-native-purchases-ui).
subscriptionStore mirrors CustomerInfo (isPremium, plan, billing platform, renewal date) and
re-syncs on app foreground to catch pending purchases.
Entitlements (backend as source of truth)
store/entitlementsStore.ts treats the backend (/me/plan, /me/entitlements) as
authoritative — not the client. RevenueCat webhooks update the backend asynchronously; the app
fetches plan state from the backend and can force a /me/sync-entitlements reconciliation. The
client is deliberately conservative: it won't show premium until the backend confirms, which
prevents client-side spoofing of entitlement state. FeatureGate components and
useEntitlements hooks gate UI; the real enforcement lives server-side at the AI endpoints
(e.g. a locked persona returns 403 personality_locked).
9. State management
~30 domain-scoped Zustand stores rather than one global store — authStore,
subscriptionStore, entitlementsStore, multiBabyProfileStore, feedingStore,
sleepV2Store, notesStore, remindersStoreV2, memoryStore, notificationStore,
personalityStore, journeyStore, syncQueueStore, and more.
Patterns:
- Per-feature slices keep state cohesive and let each store rehydrate independently (no monolithic rehydration stall on launch).
- Persistence middleware: most stores persist via
createJSONStorage(AsyncStorage);authStoreuses asecureStoreadapter for sensitive bits. - Multi-baby isolation: stores key data by baby (
Record<babyId, Data>) so households with multiple children don't cross-contaminate. - Cache-busting: stores track
lastFetchtimestamps and exposeforce refresh. - Clear coordination with the other tiers: Zustand holds hot UI state, SQLite holds durable
structured data, TanStack Query holds server cache — each store reads/writes the appropriate
tier (e.g.
memoryStorewrites SQLite, enqueues uploads, and reconciles cloud keys).
Why Zustand over Redux/Context: Redux's action/reducer/middleware ceremony is overkill here, and Context at this scale causes re-render storms. Zustand gives hook-based access, selective subscriptions, and built-in persistence with almost no boilerplate.
10. Observability: analytics & error monitoring
Two complementary systems, by design:
PostHog (product analytics) — lib/analytics/
- A singleton
AnalyticsServicewithautocapturedisabled in favor of an explicit, typed event taxonomy (lib/analytics/events.ts): lifecycle, auth, onboarding funnels, care-logging (SLEEP_LOGGED,FEEDING_LOGGED, …), memory/video funnels, care-team invites, activation milestones, and premium upsell events. - A global context (platform, app version, device, baby count, subscription tier) is merged into every event.
- Identity is bridged from Clerk via
useAnalyticsAuth(alias anonymous→known on login, reset on logout), and screen views auto-track through Expo Router. - Domain-specific tracking modules (
sleepTracking,foodTracking,chatActivationTracking, …) keep instrumentation close to features.
Sentry (engineering observability) — app/_layout.tsx, metro.config.js
- Crash/error reporting, React Navigation tracing, native frame tracking, session replay (sampled), and a feedback integration.
- The Metro plugin (
getSentryExpoConfig) wires source-map upload so production stack traces symbolicate.
Splitting them is deliberate: PostHog answers "are users activating and converting?"; Sentry answers "is the app healthy and fast?" Neither is redundant.
11. Notifications & the event bus
Push & local notifications (expo-notifications, components/notifications/):
PushTokenSyncregisters the Expo push token + a persisted device ID with the backend (POST /api/device/expo-token) — and gracefully no-ops on simulators.NotificationHandlerlistens for taps and deep-links to the right screen (appointment detail, emergency follow-up, etc.).- Reminders schedule local notifications (one-shot and recurring) — no server round-trip needed for a reminder to fire.
- The root notification handler shows a custom in-app banner rather than the OS banner, for on-brand presentation.
In-app event bus (eventemitter3, lib/events/):
A lightweight pub/sub for cross-feature signals that shouldn't be modeled as shared state — most
notably personality changes (PERSONALITY_CHANGED, SEND_CONTEXTUAL_MESSAGE,
PERSONALITY_INTRO_REQUESTED). The distinction is clean: Zustand stores the fact ("the user
picked Gentle Coach"); the event bus broadcasts the moment ("they just changed it"), which
chat, the Today card, analytics, and recommendations all react to independently. This keeps
otherwise-unrelated features decoupled.
12. Security posture
This section is intentionally balanced — strong foundations, with a few honest hardening items.
Strengths
- No LLM keys on device. All AI runs through the backend proxy (§7). The thing most apps get wrong, this app gets right.
- Secrets-at-rest in the OS keystore. Auth tokens use
expo-secure-store(Keychain/Keystore), never AsyncStorage. - Backend-authoritative entitlements. Premium state is verified server-side and enforced at the API; the client is conservative and can't self-grant access (§8).
- Token hygiene. Short-lived JWTs via a dedicated template, centralized retrieval, and
automatic refresh-and-retry on
401. - Privacy-conscious permissions. The Android config explicitly blocks
READ/WRITE_EXTERNAL_STORAGEandREAD_MEDIA_IMAGES/VIDEO, requesting onlyRECORD_AUDIOandPOST_NOTIFICATIONS. iOS usage strings are specific and honest.ITSAppUsesNonExemptEncryption: falseis declared. - Honest privacy copy (
constants/PrivacyStrings.ts): it states baby profiles/logs stay on device while AI features use secure cloud services — it deliberately avoids the false "never leaves your device" claim that a cloud AI app can't make. - Transport security. All API/storage traffic is HTTPS with JWT auth.
Hardening opportunities (called out honestly)
- Hardcoded third-party keys in
src/config/environment.ts/app.config.ts. The Shotstack API key and the storage-gateway API key (storageApiKey) are embedded in the client bundle. Publishable keys (Clerkpk_*, RevenueCatappl_*/goog_*, PostHogphc_*) are designed to be public and are fine. The Shotstack and gateway keys are not — they should move behind the authenticated backend so the client calls your API and the server holds the secret. (The gateway key in particular looks like a shared static secret.) Sentry.sendDefaultPii: trueships IP/user context to Sentry; for a baby-data app, consider scrubbing PII or making it opt-in.- Session replay: disabled in production via feature flag (good) — keep it that way given the sensitivity of the data on screen.
- Client feature gates are cosmetic by nature; the architecture correctly backs them with server-side enforcement, which is where it counts.
For a portfolio, this is a feature, not a bug: the doc demonstrates the ability to reason about security tradeoffs — what's safe to embed, what must be proxied, and why backend-authoritative design matters.
13. Performance engineering
Concrete, evidence-backed optimizations (not aspirational):
| Technique | Where | Benefit |
|---|---|---|
| New Architecture (Fabric/TurboModules/JSI) | newArchEnabled: true | Native-thread rendering + zero-copy native calls; removes the legacy bridge bottleneck |
| Hermes bytecode | Expo 54 default | Faster cold start, lower memory than JSC/V8 |
| FlashList virtualization | 15+ log/gallery screens (sleep/food/diaper/notes logs, journal, chat favorites) | 60fps scrolling over hundreds of rows; recycling instead of mounting everything |
| Reanimated v4 + Worklets | 20+ screens; carousels, entrances, chat | Animations run on the UI thread, immune to JS-thread stalls |
expo-image | ~56 usages | Native disk/memory caching, AVIF/WebP, progressive decode |
| Client-side image compression | lib/utils/imageCompression.ts | Resizes to ≤1024px, quality-steps down to ≤5MB before upload — saves bandwidth & backend cost |
TanStack Query staleTime (5 min) | global + per-hook | Eliminates redundant refetches on tab switches |
| Platform-split screens | ChatScreen.ios.tsx vs .android.tsx | iOS uses KeyboardAvoidingView; Android uses gesture keyboard dismissal + Reanimated keyboard tracking with a simplified view tree |
| Memoization | ~1,290 memo/useMemo/useCallback sites | Prevents avoidable re-renders in lists and heavy computations |
| Font preloading + held splash | lib/fonts.ts, _layout.tsx | No font-flash; no layout shift on first paint |
| Web tree-shaking of mobile-only deps | metro.config.js resolveRequest stubs expo-sqlite/expo-secure-store/Clerk on web | Smaller web bundle, no broken native imports |
| Lazy tab data | e.g. ResourceTopicScreen | Only the active tab fetches; defers the rest |
The throughline: keep the JS thread free (offload to native via Reanimated/Fabric, virtualize lists), avoid redundant work (query cache, memoization), and shrink payloads (image compression, web stubbing).
14. Build, release & tooling
EAS Build + OTA Updates
- Three build profiles (
eas.json):development(dev client, internal),preview(internal APK, prod-like keys),production(autoIncrement: true, prod keys). Each maps to an EAS Update channel. - OTA strategy:
expo-updateswithruntimeVersion.policy: "appVersion". JS-only fixes ship viaeas update(the npmupdate:*scripts stamp the git commit message as the update note) without an app-store review cycle — critical for hotfixing a chat or logging bug in minutes. Native changes bump the runtime version and require a real build, so OTA payloads can never be incompatible with the installed native runtime. - Submission config carries the App Store
ascAppIdand PlayapplicationId; nativeios/andandroid/projects are committed (prebuilt), so the project can drop to bare native when needed.
Compilation & config
- Metro (
metro.config.js): wrapped with NativeWind and Sentry; a customresolveRequeststubs mobile-only modules on web;metro-minify-terserfor smaller bundles. - Babel:
babel-preset-expowithjsxImportSource: "nativewind"soclassNameworks on RN components without per-file imports. - TypeScript — aggressively strict (
tsconfig.json):strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride,noUnusedLocals/Parameters,noImplicitReturns,noFallthroughCasesInSwitch. These flags turn whole classes of runtime bugs (array out-of-bounds, accidentalundefined, silent override drift) into compile errors. Path alias@/*maps to the repo root. - ESLint (
eslint-config-expo) enforces conventions (named effect callbacks, magic-string avoidance, string extraction).
15. Testing strategy
Unit / logic — Jest + jest-expo + Testing Library (jest.config.js, jest.setup.js):
- Native modules (Clerk, RevenueCat, secure-store, notifications, haptics, Reanimated, PostHog)
are mocked in
jest.setup.jsso logic runs in Node. - Coverage targets
lib/,components/,app/. The suite concentrates on the highest-risk money & safety logic: subscription/entitlement resolution (incl. expired/malformed entitlements, Apple-vs-Google platform detection), backup+restore with premium validation, personality gating, grace/trial periods, story generation, app-rating prompts, and emergency-pattern detection. Service wrappers (auth/billing/storage/analytics) have their own tests.
E2E — Maestro (.maestro/, maestro/flows/):
- Declarative YAML flows over the real app: onboarding+setup, premium upgrade, free-vs-premium
chat limits, and backup+restore. Run via
yarn test:e2e;yarn test:allruns unit then E2E.
The split is intentional: Jest verifies the money/safety invariants that must never regress; Maestro verifies the critical user journeys end-to-end.
16. Design system
A written master style guide (STYLE_GUIDE.txt) plus a NativeWind/Tailwind token system
(tailwind.config.js) keep the UI cohesive and on-brand:
- Palette: navy surfaces (
#0B0F14), a single canonical mint accent (#7FE1C8), sky blue secondary (#7C9EFF), with a strict "mint glow unification" rule — every mint element (CTAs, badges, checkmarks, selection borders) uses the same mint + glow, eliminating design drift. - Type: Inter (body) + Outfit (display), with a defined size/weight hierarchy.
- Surfaces: glass backgrounds, generous radii (16–26px), spring-eased 180–250ms transitions — the "warm, premium, magical" feel (Apple × Notion × Calm) the product is going for.
NativeWind compiles Tailwind classes to RN StyleSheet objects at build time, so there's no
runtime CSS cost — utility-first DX with native performance.
17. Key decisions & tradeoffs (summary)
| Decision | Why | Tradeoff accepted |
|---|---|---|
| Offline-first (SQLite + outbox) | Parents log without signal; data loss is unacceptable | Sync complexity; last-write-wins instead of CRDTs |
| Backend-proxied AI | Protect keys, swap models without releases, enforce safety server-side | Backend must be available for live AI; offline shows cached only |
| Expo + committed native projects | Velocity of managed Expo, escape hatch of bare native | Larger repo; must keep pods/gradle in sync |
| EAS OTA updates | Ship JS fixes in minutes, not days | Discipline required around runtimeVersion compatibility |
| Zustand (≈30 stores) over Redux | Minimal boilerplate, fine-grained subscriptions | Conventions, not a framework, enforce consistency |
| Three storage tiers | Right tool per data class (secure/structured/kv) | More moving parts to reason about |
| Backend-authoritative entitlements | Prevent client-side premium spoofing | Extra round-trips; eventual consistency with RevenueCat webhooks |
| v2-alongside-v1 features | Strangler-fig migration without big-bang risk | Temporary duplication during transition |
| New Architecture + Reanimated/FlashList | Native-quality 60fps feel | Bleeding-edge RN; some libs must support new arch |
| Strict TypeScript | Compile-time safety on a large surface | More upfront friction; stricter code |
| PostHog + Sentry (both) | Product analytics ≠ engineering observability | Two SDKs, two dashboards |
18. Known hardening items
A short, honest backlog (useful to show what "done" looks like):
- Proxy the Shotstack and storage-gateway keys through the authenticated backend; remove them from the client bundle.
- Tighten Sentry PII (
sendDefaultPii) and confirm replay stays off in production for a baby-data app. - Promote the dual-store migration — finish moving legacy
profileStore(SQLite) consumers ontomultiBabyProfileStoreand retire v1 feature routes once v2 is stable. - Replace connectivity polling with event-driven listeners — swap the 10-second poll for
expo-network/NetInfosubscriptions to trigger sync on the offline→online edge with less battery cost. - Add a distributed-safe rate-limit/coalescing layer for sync retries so a burst of queued uploads after a long offline window backs off gracefully rather than thundering on reconnect.
- Expand E2E coverage to the emergency-mode and care-team invitation journeys, which are the highest-stakes flows not yet fully scripted in Maestro.