Joshua Damon
FlagshipShipped2026

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

React NativeExpo SDK 54TypeScriptZustandSQLiteClerkRevenueCat

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

60 fpsNew Arch UI
Offlinedata layer
iOS + Androidapp stores
Engineering Case Study
Internal Document

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 problemArchitectural answer
Parents log data at 3 AM with no signalOffline-first: SQLite + a durable sync queue with exponential backoff
AI must feel personal and safeBackend-proxied AI with per-request baby context injection; no model keys on device
One codebase, two platforms, premium feelNew Architecture + Reanimated v4 + FlashList, platform-split screens, a strict design system
Ship fixes fast, monetize, measureEAS 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

ConcernChoiceWhy this over alternatives
FrameworkExpo SDK 54 + RN 0.81.5Managed-but-ejectable: native ios/+android/ projects are committed, but Expo's module ecosystem, EAS, and OTA updates are retained. Best of both worlds.
RuntimeNew Architecture (Fabric/TurboModules/JSI) + HermesNative-thread rendering and zero-copy JS↔native calls; unlocks 60fps animations. Hermes ships bytecode for faster cold start.
RoutingExpo Router 6 (file-based) + typedRoutesConvention-over-config routing with compile-time-typed navigation; deep links and route groups for free.
Server stateTanStack Query 5Caching, dedup, background refetch, retry policy — without hand-rolling a fetch cache.
Client stateZustand 5Hook-based, minimal boilerplate, fine-grained subscriptions, first-class persistence middleware. No Redux ceremony, no Context re-render storms.
Local DBexpo-sqliteRelational queries, transactions, indexes for thousands of log entries — AsyncStorage can't do this.
AuthClerk (@clerk/clerk-expo)Managed OAuth (Apple/Google), 2FA/TOTP, secure token cache, session lifecycle. Auth is a liability you don't want to own.
PaymentsRevenueCatCross-platform IAP/subscription abstraction with server-side receipt validation + webhooks.
StylingNativeWind 4 (Tailwind → RN StyleSheet)Utility-first styling compiled at build time (zero runtime CSS parser), enforced by a written design system.
Forms/validationreact-hook-form + Zod 4Uncontrolled-input performance + schema validation shared between forms and API payloads.
Lists@shopify/flash-list 2Virtualized recycling for long logs/galleries — FlatList jank eliminated.
AnimationReanimated 4 + WorkletsAnimations driven on the UI thread, immune to JS-thread stalls.
AnalyticsPostHog (+ session replay)Product funnels, retention, replay — self-serve and event-typed.
Errors/perfSentryCrash reporting, tracing, release health, source-map upload via Metro plugin.
AIBackend proxy (/api/ai/*)Keeps model keys server-side; lets the provider/model change without an app release.
Media/videoCloudflare R2 + Worker gateway, ShotstackCheap object storage behind a Worker; Shotstack renders AI memory videos server-side.
CI/ReleaseEAS Build + EAS UpdateCloud builds, channels, and OTA JS delivery.
TestingJest + Testing Library + MaestroUnit/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:

  1. Screens and components read and write through Zustand stores for hot UI state, and read server-owned data through TanStack Query.
  2. Domain writes land locally first in SQLite (instant UI feedback), and durable side-effects (media uploads, backups) are enqueued into the sync queue.
  3. 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.
  4. AI requests go through lib/api/aiClient to api.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 hidden journeys route. 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.

DomainWhat it does
ChatThe AI co-parent conversation; streaming replies, quick prompts, saved/favorite messages, voice input
TodayDaily dashboard: AI greeting card, proactive follow-ups, reminders, quick-add
My BabyMulti-baby profiles, growth tracking, medical card, care team & caregiver invitations
Journal / Journal-v2Photo/memory albums; v2 adds day/week views, archive, export
NestDiscover hub — parenting resources, articles, expert/video content
Sleep / Sleep-v2Sleep logging → v2 adds plans, a setup wizard, trends, and a "sleep whisperer" coach
FoodFeeding logs (breast/formula/solids), food introduction tracking, meal/week plans, grocery lists, milk trends
DiapersDiaper logging + analytics + export
Memory StoriesAI-generated cinematic video reels from selected memories (rendered via Shotstack)
Journeys / Journeys-v2Guided multi-step experiences (sleep setup, feeding intro) with progress + premium gating
Reminders / ScheduleLocal-notification-backed reminders and a calendar
Notes / DocumentsQuick notes (search/stats) and document storage (medical records)
Emergency modeA full-screen safety gate with follow-up flows
Support / FeedbackIn-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 = ON with cascade deletes for referential integrity.
  • ~30 indexes on user_id / baby_profile_id / created_at because 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:

  1. Write locally first. The user action writes to SQLite and the UI updates immediately — no spinner, no waiting on the network.
  2. 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.
  3. Wait for real connectivity. When NetworkProvider reports the device is back online, it nudges the worker.
  4. Drain in batches. The worker pulls pending tasks, marks each syncing, and uploads to the backend / storage gateway.
  5. Resolve. Success marks the task completed and stores the returned cloud key. A 5xx/timeout/429 retries with backoff; a 4xx fails 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, plus 408/429) are retried; client errors (4xx like 400/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, and NetworkProvider surfaces 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 on 401 (the token is stale — refresh it, don't replay), otherwise up to 2 attempts.
  • 5-minute staleTime default: 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. useAppOpenContext fetches 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 a shown → engaged/dismissed lifecycle for analytics.
  • Decoupled propagation. Personality changes broadcast over an eventemitter3 bus (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.ts exposes getAuthToken() (using a dedicated nestleai-backend JWT template) and a fetchWithAuth() wrapper that auto-retries once with a fresh token on 401 — handling the common "stale token after long background" case.
  • Route protection: the (auth) group redirects authenticated users home; SessionExpiryGuard catches 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); authStore uses a secureStore adapter 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 lastFetch timestamps and expose force 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. memoryStore writes 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 AnalyticsService with autocapture disabled 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/):

  • PushTokenSync registers the Expo push token + a persisted device ID with the backend (POST /api/device/expo-token) — and gracefully no-ops on simulators.
  • NotificationHandler listens 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_STORAGE and READ_MEDIA_IMAGES/VIDEO, requesting only RECORD_AUDIO and POST_NOTIFICATIONS. iOS usage strings are specific and honest. ITSAppUsesNonExemptEncryption: false is 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 (Clerk pk_*, RevenueCat appl_*/goog_*, PostHog phc_*) 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: true ships 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):

TechniqueWhereBenefit
New Architecture (Fabric/TurboModules/JSI)newArchEnabled: trueNative-thread rendering + zero-copy native calls; removes the legacy bridge bottleneck
Hermes bytecodeExpo 54 defaultFaster cold start, lower memory than JSC/V8
FlashList virtualization15+ log/gallery screens (sleep/food/diaper/notes logs, journal, chat favorites)60fps scrolling over hundreds of rows; recycling instead of mounting everything
Reanimated v4 + Worklets20+ screens; carousels, entrances, chatAnimations run on the UI thread, immune to JS-thread stalls
expo-image~56 usagesNative disk/memory caching, AVIF/WebP, progressive decode
Client-side image compressionlib/utils/imageCompression.tsResizes to ≤1024px, quality-steps down to ≤5MB before upload — saves bandwidth & backend cost
TanStack Query staleTime (5 min)global + per-hookEliminates redundant refetches on tab switches
Platform-split screensChatScreen.ios.tsx vs .android.tsxiOS uses KeyboardAvoidingView; Android uses gesture keyboard dismissal + Reanimated keyboard tracking with a simplified view tree
Memoization~1,290 memo/useMemo/useCallback sitesPrevents avoidable re-renders in lists and heavy computations
Font preloading + held splashlib/fonts.ts, _layout.tsxNo font-flash; no layout shift on first paint
Web tree-shaking of mobile-only depsmetro.config.js resolveRequest stubs expo-sqlite/expo-secure-store/Clerk on webSmaller web bundle, no broken native imports
Lazy tab datae.g. ResourceTopicScreenOnly 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-updates with runtimeVersion.policy: "appVersion". JS-only fixes ship via eas update (the npm update:* 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 ascAppId and Play applicationId; native ios/ and android/ 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 custom resolveRequest stubs mobile-only modules on web; metro-minify-terser for smaller bundles.
  • Babel: babel-preset-expo with jsxImportSource: "nativewind" so className works 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, accidental undefined, 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.js so 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:all runs 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)

DecisionWhyTradeoff accepted
Offline-first (SQLite + outbox)Parents log without signal; data loss is unacceptableSync complexity; last-write-wins instead of CRDTs
Backend-proxied AIProtect keys, swap models without releases, enforce safety server-sideBackend must be available for live AI; offline shows cached only
Expo + committed native projectsVelocity of managed Expo, escape hatch of bare nativeLarger repo; must keep pods/gradle in sync
EAS OTA updatesShip JS fixes in minutes, not daysDiscipline required around runtimeVersion compatibility
Zustand (≈30 stores) over ReduxMinimal boilerplate, fine-grained subscriptionsConventions, not a framework, enforce consistency
Three storage tiersRight tool per data class (secure/structured/kv)More moving parts to reason about
Backend-authoritative entitlementsPrevent client-side premium spoofingExtra round-trips; eventual consistency with RevenueCat webhooks
v2-alongside-v1 featuresStrangler-fig migration without big-bang riskTemporary duplication during transition
New Architecture + Reanimated/FlashListNative-quality 60fps feelBleeding-edge RN; some libs must support new arch
Strict TypeScriptCompile-time safety on a large surfaceMore upfront friction; stricter code
PostHog + Sentry (both)Product analytics ≠ engineering observabilityTwo SDKs, two dashboards

18. Known hardening items

A short, honest backlog (useful to show what "done" looks like):

  1. Proxy the Shotstack and storage-gateway keys through the authenticated backend; remove them from the client bundle.
  2. Tighten Sentry PII (sendDefaultPii) and confirm replay stays off in production for a baby-data app.
  3. Promote the dual-store migration — finish moving legacy profileStore (SQLite) consumers onto multiBabyProfileStore and retire v1 feature routes once v2 is stable.
  4. Replace connectivity polling with event-driven listeners — swap the 10-second poll for expo-network/NetInfo subscriptions to trigger sync on the offline→online edge with less battery cost.
  5. 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.
  6. Expand E2E coverage to the emergency-mode and care-team invitation journeys, which are the highest-stakes flows not yet fully scripted in Maestro.