ADR-0003 — Expo-managed React Native, iOS first
| Date | 2026-08-17 |
| Superseded by | — |
Context
Two requirements pull against each other.
"It has to feel like a slick native iOS app." Liquid Glass, SF Symbols, native tabs, the system's own gestures and haptics. Historically that argues for Swift and SwiftUI.
"Every PR gets a build I can test from my phone by scanning a QR code." Over-the-air updates in under a minute, no TestFlight review, no Xcode. That argues hard against a pure native build, where every change is a fresh archive and an App Store Connect upload.
There is also a practical constraint: one developer, four deployables, and a documentation obligation. Two codebases is not the place to spend the budget.
Decision
The app is React Native under Expo (SDK 57), targeting iOS only, delivered to previews via EAS Update.
The bet is that modern Expo closes most of the native-feel gap:
expo-glass-effectgives real Liquid Glass —GlassView, withisLiquidGlassAvailable()to branch honestly when it is not there.expo-router'sNativeTabsrenders an actualUITabBar, includingminimizeBehavior="onScrollDown". Not a JS reimplementation.expo-symbolsgives real SF Symbols, weight-matched to the system font.react-native-screensgives the real native stack and the real interactive back-swipe.@expo/uigives SwiftUI-backed components where we need them.
Scope decisions that come with it:
- iOS only.
app.config.tskeeps Android and web keys so a future decision is a small diff, but nothing is tested on them and no Android-specific workaround goes in without a new ADR. deploymentTarget: '18.0', and the Liquid Glass path needs newer.- EAS Update for previews, on a per-PR branch, with the PR's API URL baked into the update via
EXPO_PUBLIC_API_URL. See ADR-0006. - Native modules are pinned to the SDK. Add dependencies with
expo install, notpnpm add.
Consequences
What gets harder:
- We are downstream of Expo's release cycle. A new iOS design language lands when
expo-glass-effectsupports it, not when Apple ships it. For an app whose selling point is feeling current, that is the real cost of this decision. - The gap is closed, not eliminated. Some things will still be worse than SwiftUI. When they are, the answer is a native module, not a JavaScript approximation.
- Adding a native module needs a new dev build, not just a reload. That has to be said in the PR description or reviewers waste an afternoon.
- Bundle size and startup need watching. Hermes plus a large JS bundle is not free.
What we are committed to:
- Expo's managed workflow and EAS.
expo prebuildis available if we ever need to eject to bare, and doing so gives up EAS Update's convenience. - Recent iOS. Users on iOS 17 are not supported.
What this creates:
- An
EXPO_TOKENsecret and an EAS project in the deploy pipeline. - A discipline around
expo install --checkin CI, because version drift between the SDK and its native modules is the classic Expo failure.
Reversing it: rewriting in SwiftUI is a full rewrite of the app layer. The API, the data model, the sync protocol, and the docs all survive, which is roughly half the work. Expensive but not catastrophic — and the offline-first design means the hard part (the sync engine) is specified well enough to reimplement.
Alternatives considered
Native Swift / SwiftUI
The best possible native feel, first access to every Apple API, no framework in the way.
Rejected on the preview requirement. Getting a build onto a phone per PR means TestFlight, which means processing time and review friction — nothing like scanning a QR code and having the new build in seconds. That workflow is a stated requirement, and it is the thing that makes iterating on this project pleasant.
Genuinely the strongest alternative. If the native-feel gap turns out to be wider than expected, this is what supersedes this ADR.
React Native bare workflow
Full native control, keeps React. Rejected because it gives up EAS Update and adds an Xcode
project to maintain by hand, for a level of control we have no identified need for. expo prebuild
is the escape hatch if that changes.
Flutter
Good performance, one codebase. Rejected because it does not use the platform's own controls — it draws its own. That is the opposite of the requirement here. Cupertino widgets are an imitation of iOS, and imitation is what we are trying to avoid.
A web app or PWA
Cheapest by far. Rejected on offline capability, on iOS PWA limitations, and on native feel. The offline requirement alone is disqualifying.
Open questions
- How wide is the remaining gap? Sheet detents, context menus, haptics, and Dynamic Type are the concrete tests. See the checklist in iOS native feel.
- What is the trigger for reconsidering? Suggested: if two or more roadmap items in a phase require custom native modules to feel right, revisit.
- Android. Not planned. Reversing the iOS-only scope needs a new ADR superseding this one,
because
NativeTabs, Liquid Glass and SF Symbols all need Android equivalents that are not drop-in.