TL;DR

I’m shipping Migraine Tracker, a mobile health app on React Native 0.81 + Expo 54 with an Express 5 + PostgreSQL + Drizzle backend, where the FHIR 4.0.1 export is actually verifiable byte-for-byte against the spec. A neurologist who receives the file can import the patient’s migraine history directly into their EHR system, rather than re-typing it from screenshots.

This article covers what “real FHIR” looks like in code, how to model a migraine episode as an Observation resource (it’s not a single value), how SNOMED CT and HL7 terminology systems actually slot in, and the React Native + Expo decisions that made the rest of the build sane.

What most “FHIR-compliant” mobile health apps actually ship

The category is full of apps that advertise FHIR but ship something like this:

{
  "type": "migraine",
  "severity": 7,
  "triggers": ["stress", "caffeine"],
  "started": "2026-05-10T08:00:00Z",
  "medicationTaken": "Sumatriptan 50mg"
}

That’s not FHIR. It’s JSON with health-sounding keys. A neurologist trying to import it into an EHR system gets nothing — the system has no idea what "type": "migraine" means in a coded vocabulary. There’s no resourceType, no code.coding, no subject reference, no terminology system.

Real FHIR 4.0.1 of the same record looks much more like:

{
  "resourceType": "Observation",
  "status": "final",
  "code": {
    "coding": [{
      "system": "http://snomed.info/sct",
      "code": "37796009",
      "display": "Migraine"
    }]
  },
  "subject": { "reference": "Patient/abc123" },
  "effectivePeriod": {
    "start": "2026-05-10T08:00:00Z",
    "end":   "2026-05-10T14:00:00Z"
  },
  "valueQuantity": {
    "value": 7,
    "unit": "1-10",
    "system": "http://unitsofmeasure.org",
    "code": "{score}"
  },
  "component": [
    {
      "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1101000175103", "display": "Stress trigger" }] },
      "valueBoolean": true
    },
    {
      "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1101000175104", "display": "Caffeine trigger" }] },
      "valueBoolean": true
    }
  ]
}

That an EHR can import. Every concept has a coded system. Every relationship is explicit. The subject is a reference to a Patient resource that exists separately. Medications taken during the episode are separate MedicationStatement resources linked back to the same patient and time period.

Modelling a migraine episode as an Observation

Here’s where most implementations get stuck. FHIR 4.0.1’s Observation resource is primarily designed for measurements — a heart rate reading, a blood pressure entry, a glucose value. A migraine is an episodic event with onset, duration, severity, triggers, and associated medications. It doesn’t fit cleanly.

The mapping I settled on:

Migraine conceptFHIR slot
The fact that it was a migrainecode.coding with SNOMED CT 37796009 (“Migraine”)
Onset + endeffectivePeriod.start + effectivePeriod.end
Severity (0–10)valueQuantity.value with unit: "1-10" and a custom-namespace code
Each triggercomponent[i] with its own code.coding (SNOMED) and valueBoolean: true
Each medication takenA separate MedicationStatement resource referencing the same subject + an effectivePeriod overlapping the episode

Triggers as component entries rather than as a single comma-separated string matters: it means an EHR can run a query like “show me episodes triggered by stress” without having to parse free text.

Building the FHIR layer (Express + Drizzle + SNOMED)

The backend is Express 5 + PostgreSQL + Drizzle ORM + Better Auth. The FHIR layer has its own four routes with dedicated auth middleware, because clinical exports follow a different auth flow than the regular API calls — a doctor receiving an export is not necessarily the user who created the data.

The routes:

  • GET /fhir/Patient/:id — the patient resource
  • GET /fhir/Observation?patient=:id — search bundle of episodes
  • GET /fhir/MedicationStatement?patient=:id — search bundle of medications
  • GET /fhir/Bundle/:id — the full export bundle, ready to drop into an EHR’s import flow

The transformation from the Drizzle row to the FHIR resource is a pure function. That’s the part where I verified output against the 4.0.1 spec byte by byte, ensuring every required field is present and every conditional field that should appear does appear.

Worth knowing if you’re doing this: Drizzle is the right call over Prisma here because the migraine + trigger + medication entities are highly relational and you want explicit SQL when constructing the bundle. Prisma’s auto-generated includes start to fight you when you’re building five-resource bundles in one round trip.

Why React Native + Expo specifically

The mobile app needed to:

  • Work offline (migraines often start where Wi-Fi doesn’t, and the user needs to log onset now, not later)
  • Run on both iOS and Android with one codebase
  • Get medication history out of the iOS Health app where users already have it (HealthKit entitlements for health-records)
  • Talk to Android Health Connect on Android for the same purpose
  • Sync bidirectionally with the backend when the network returns

React Native 0.81 + Expo 54 was the deliberate choice because:

  1. One codebase, two platforms. A Swift + Kotlin pair would have meant two FHIR layers, two HealthKit integrations, two offline queues. Sharing the FHIR transformation across iOS and Android matters here — it’s the part most likely to have subtle bugs.
  2. Expo managed workflow gives OTA updates. A doctor reports a bug in the export format. I push a JS bundle update; the patient population gets the fix the next time the app opens. No App Store review cycle for a critical clinical-data fix.
  3. HealthKit entitlements work through Expo. With health-records entitlement (which Apple gates carefully), the user can import existing medication history from the iOS Health app rather than typing it.
  4. Sentry on both frontend and backend. Sentry’s React Native SDK is mature; production crashes get observability.

The offline sync

The mobile app uses expo-sqlite for local storage. The schema mirrors the server’s Drizzle schema. A /api/sync endpoint provides bidirectional sync with last-write-wins semantics on the server.

Why last-write-wins? Because this is single-user personal health data. There is exactly one human writing to this database. There can’t be a “merge conflict” between two devices in a meaningful sense — the user is one of them. Last-write-wins is the right semantic here. Predictable, no conflict-resolution UI, no anxiety.

Verification: how I know the FHIR is real

The 16 backend test suites (Jest + supertest) cover the FHIR layer. The two that matter most:

  • fhir-spec-compliance.test.ts — runs every Observation, Patient, and MedicationStatement through a 4.0.1 schema validator. Any spec drift fails CI.
  • fhir-import-replay.test.ts — generates an export bundle for a synthetic patient, sends it to a sandbox EHR, parses what comes back, asserts the imported record matches the source.

The second one is the test that closes the loop on “is this actually FHIR.” An EHR system either imports it or it doesn’t. There’s no marketing in between.

What’s still ahead

Migraine Tracker is currently pre-launch — App Store and Play Store submission is pending final asset polish and a Sentry DSN for production. The HealthKit entitlement requires a specific Apple approval flow that I’m in the middle of.

If you’re building a mobile health app and need real FHIR 4.0.1 rather than the marketing version — get in touch.