Skip to content
Regimio

Product

Local-first health app architecture in Expo: SQLite, Zustand, no backend

How we built Regimio with zero server infrastructure for the v0 release. SQLite + Drizzle + Zustand, opt-in iCloud sync planned for v1, no backend required.

Lance Sessions·Apr 28, 2026·7 min read

Most health apps start with a backend. Auth service, Postgres, REST API, push notification server, analytics pipeline, the works. Then they figure out what the app should actually do.

Regimio went the other way. We started from a constraint: no server stores user data in v0. Everything runs on-device. The backend can come in v1 if and only if it's needed for a feature.

Here's the architecture, the trade-offs, and what we'd change.

The stack at v0

  • Expo SDK 54 with Expo Router v4
  • TypeScript strict
  • Zustand (state) + Immer (mutations)
  • expo-sqlite + Drizzle ORM (local persistence)
  • react-native-svg + react-native-skia (charts and body map)
  • expo-notifications (local notifications only · no APNs/FCM)
  • Health integrations (planned after MVP)
  • expo-apple-authentication (optional sign-in)
  • expo-secure-store (Keychain-backed key storage)
  • Sentry (opt-in only, scrubbed of values)
  • RevenueCat (subscription receipts, no user data)

Total backend infrastructure: zero.

Total monthly server cost for free-tier users: zero.

Why SQLite + Drizzle, not AsyncStorage

AsyncStorage is the easy choice. It's key-value, it's built into Expo, it doesn't require a schema migration.

We needed real relational queries. A user's stack has compounds, protocols (versioned, with superseded_by links), vials (with reconstitution state and 28-day expiry), dose logs (with site + route + protocol foreign keys), symptom logs, lab results, and wearable data. Querying that with AsyncStorage means writing a JSON parser and praying.

SQLite via expo-sqlite gives us:

  • Real foreign keys
  • Indexed queries (fast load on home screen)
  • Transactional writes (no half-saved doses)
  • A migration story that scales as the schema evolves

Drizzle ORM gives us:

  • Type-safe queries · bugs surface at compile time, not runtime
  • Schema-as-code · migrations are diffs we can review
  • No ORM impedance mismatch · queries read like SQL

The Zustand split

We have three Zustand stores, deliberately small:

useStackStore   // compounds, protocols, vials, activeStack (computed)
useLogStore     // doseLogs, symptomLogs, recentLogs (last 30d hot)
useScoreStore   // todaysScore, scoreHistory, currentInsight

Each store hydrates from SQLite on mount and writes through on mutation. State in memory; truth on disk.

We considered a single global store with selectors. Splitting was the right call because:

  • Each store has a single responsibility and a clear ownership boundary
  • Re-renders are scoped · touching logDose() doesn't re-render score components
  • Testing is straightforward · pass a mock store to a screen, assert the output

State persistence

The pattern looks like this:

const useStackStore = create<StackState>()(
  persist(
    immer((set, get) => ({
      compounds: [],
      addCompound: async (input) => {
        const inserted = await db.insert(compoundsTable).values(input).returning();
        set((s) => { s.compounds.push(inserted[0]); });
      },
      // ...
    })),
    {
      name: "regimio-stack",
      storage: createJSONStorage(() => AsyncStorage),
      // SQLite is the source of truth; AsyncStorage is a hot cache for first render
    }
  )
);

The AsyncStorage cache is only for first-render speed. SQLite is the truth. On mount, we hydrate from AsyncStorage immediately (showing cached data within 50ms of app open), then run a SQLite query in the background to refresh.

This is what makes Regimio's home screen feel instant. The cache is yesterday's data. The database is today's data. They reconcile in the background within ~200ms.

Why no backend for v0

Three reasons:

1. The product doesn't need one. Regimio's daily loop is: open app → see today's stack → log a dose → see the next dose. None of that requires a server. The state lives on your device because that's where it's used.

2. Privacy is a precondition. A server with user data is a server with breach exposure. A server with no user data is just an app that happens to charge for in-app purchases.

3. Operations cost. Free-tier users are 95% of our user base in early days. If every free user costs $0.001/month in compute, that's $100/month at 100,000 users. Multiply by signup growth, lambda cold starts, and 24/7 monitoring · suddenly there's a server team. We don't need one yet.

Where backend is being deferred · and what it'll add

v1 will add:

  • iCloud Drive document sync (Apple-side; we don't run it). Encrypted client-side with a key in iCloud Keychain.
  • Apple/Google subscription validation via RevenueCat (already in v0 client-side; v1 adds RevenueCat's hosted validation).
  • Optional account creation (Apple Sign-In only) for users who want sync.

v2 may add:

  • Lab PDF import (Pro roadmap) · only after the beta app is stable and the privacy model is explicit.
  • Anonymized aggregate insights ("among 1,200 users on a similar Tirz ladder, the median plateau started week 12") · strictly aggregate, never traceable to a user.

v3+ would add:

  • Multi-user / caregiver mode (read-only delegate)
  • Real-time wearable streaming from third-party APIs

Even in v3, the user's data never leaves their device unless they explicitly enable a feature that needs it. The default position is local-first, forever.

What we'd change

Three honest critiques:

1. Drizzle's migration story is still maturing. We had to write a custom migration runner for Expo because Drizzle's official migration tool assumes Node. It works, but it's the most fragile part of v0.

2. Zustand persist + SQLite reconciliation is more complex than it looks. We've seen one bug where a stale AsyncStorage cache showed a deleted dose for 200ms after the user undid it. Fix was straightforward, but the architecture invites the bug.

3. Local notifications on Android are 95% reliable. Not 99%. The Android lifecycle has more aggressive battery optimizations that occasionally delay or drop a scheduled notification. We've added a fallback that re-schedules on app open if the missed-window check sees a gap. iOS has been bulletproof.

Why this is the right architecture for this product

Regimio's user is sensitive to:

  • Privacy (private protocols, sensitive health values)
  • Speed (logging beats Excel only if it's instant)
  • Reliability (notifications must fire)
  • Cost (free tier must be sustainable)

A local-first architecture optimizes every one of those. A backend would compromise privacy, add network-latency to logging, add a remote-push reliability concern, and create per-user infrastructure costs.

The trade-off: we can't run server-side analytics on user behavior, we can't push remote feature flags as cheaply, and we can't easily orchestrate features that span multiple devices. We're fine with all three.

The constraint as feature

The whole privacy posture of Regimio · "your data stays on your device" · only reads as credible because the architecture backs it up. If we had a Postgres database storing user doses, the marketing line would be empty. With SQLite-on-device, the marketing line is the architecture diagram.

Architecture is the product, for the people who care about this.


The privacy posture walks through what that looks like for users. The security page covers the threat model and engineering practices.

Steady is a strategy.

Regimio is the bio co-pilot for TRT, peptides, GLP-1, supplements, and labs. Free forever. iOS + Android.