Shipping an Expo app through TestFlight
From expo-doctor and audit:ios to App Store Connect metadata — the operational playbook for getting an Expo app from dev build to TestFlight upload.
- Expo
- React Native
- iOS
- Mobile
Shipping an Expo app through TestFlight
#TL;DR
| Gate | Tool | What it catches |
|---|---|---|
| Config health | expo-doctor | Plugin mismatches, SDK drift |
| iOS-specific | audit:ios | Permissions, icons, privacy manifest gaps |
| Native truth | expo prebuild + Xcode archive | What Expo Go never exercises |
| Upload | Transporter + validation script | Invalid UIBackgroundModes, signing |
| Store | App Store Connect | Privacy policy URL, screenshots, export compliance |
Series: Mobile platform architecture · Firebase security (fix before you burn a build slot) · AI experiments (Gemini SSE · GPU pod)
#“Works on my phone” is not a release candidate
Expo Go hides failures that only show up in a signed IPA: native modules you never prebuilt, missing entitlements, UIBackgroundModes strings Apple rejects at upload, and privacy manifests third-party SDKs require.
Distribution was first-class engineering, not a Friday afternoon task. The playbook clusters into three layers: validate config, produce a signed IPA, pass App Store Connect paperwork.
#Layer 1: fail fast before Xcode
expo-doctor catches dependency and plugin inconsistencies while you are still in JavaScript land.
audit:ios (project-specific script) walks Info.plist expectations: camera/mic usage strings, photo library access, notification permissions, icon sets.
Honest validation standard: automated scripts cannot prove runtime crashes on device. They block known-bad uploads so you do not wait 20 minutes for Transporter to fail on UIBackgroundModes.
#UIBackgroundModes footgun
Apple rejects invalid mode strings. A common mistake is background-processing vs the exact token Apple expects (processing in the valid set for your use case). Fix flow:
expo prebuildto materializeios/- Inspect
Info.plistwithplutilor Xcode - Correct modes in
app.config/ plugin config - Re-prebuild — do not hand-edit generated native projects without tracing back to config
#Layer 2: two build paths
#EAS cloud build
Best when CI owns releases. eas build --platform ios --profile production with credentials in EAS. Pair with eas submit or manual Transporter upload.
#Local Xcode archive (preferred for native debugging)
eas build --platform ios --profile production --local
# or
npx expo prebuild --platform ios
# open ios/*.xcworkspace in Xcode → Archive → DistributeLocal builds matter when you need Xcode Organizer logs or entitlements debugging that cloud build summaries flatten away.
#Layer 3: Transporter validation
Before App Store Connect sees the IPA, run validation:
- Bundle identifier matches provisioning profile
- Code signing identity is distribution, not development
- Required device capabilities align with
@react-native-firebase/*and camera modules - No forbidden background modes
A shell script wrapped Transporter CLI checks in the project — treat it as a preflight, not a substitute for TestFlight crash reports.
#App Store Connect paperwork engineers forget
| Requirement | Why reviewers care |
|---|---|
| Privacy policy URL | Must load; social apps get scrutinized |
| Export compliance | Encryption usage questionnaire |
| Age rating questionnaire | User-generated content triggers higher scrutiny |
| Screenshots per device class | 6.7" and 5.5" minimum set |
| Subtitle + keywords | Discovery, not vanity — 30-char subtitle |
TestFlight readiness review flagged TypeScript errors in navigation and missing privacy URL as blockers — not performance, not feature count.
#Native modules that force prebuild
In this stack:
- VisionCamera — HDR photo/video, gesture handlers; not Expo Go compatible
- WebRTC calling — requires native WebRTC stack + background audio entitlements
- App Check — DeviceCheck / Play Integrity need native config plugins
If your pitch deck says “video calls” and your build pipeline stops at Expo Go, you are lying to yourself, not investors.
#WebRTC adds distribution complexity
Audio/video calling (architecture context — WebRTC + Firebase signaling) implies:
- Microphone/camera usage descriptions in plist
- Background audio mode (scrutinized)
- CallKit integration if you want native incoming-call UX on iOS
Ship calling only after TestFlight proves plain navigation + chat + camera stable. Calling multiplies failure modes.
#Release checklist (condensed)
- P0 security rules deployed
expo-doctorcleanaudit:iosclean- Production profile build succeeds locally at least once
- Transporter validation passes
- Privacy policy live
- TestFlight internal group → 24h dogfood → external beta
#IPA preflight script
Before Transporter, validate-ipa.sh unpacks the archive and fails fast on issues Apple rejects silently:
| Check | What it catches |
|---|---|
plutil -lint Info.plist | Malformed plist syntax |
Required keys (CFBundleIdentifier, version, build) | Missing metadata |
UIBackgroundModes allowlist | Invalid values like background-processing typos |
codesign -dv | Unsigned or broken signature chain |
The script extracted the .app bundle, validated background modes against Apple’s enumerated list, and printed version/build for the release notes copy-paste — saving a round trip when EAS produced an IPA with a bad plist merge.
#Closing thought
Distribution is a feature: broken entitlements and rejected privacy strings waste the same week as a flaky chat screen. Run an internal TestFlight build before you optimize animations.
#Reader field guide
Treat TestFlight as a pipeline, not a single eas build click.
Pre-upload checklist
- P0 Firebase rules deployed (security post)
-
expo-doctorclean; projectaudit:ios(or equivalent plist walk) clean - At least one local production archive or
eas build --localwhen native modules are in play -
UIBackgroundModesvalues match Apple’s allowlist (fix inapp.config, re-prebuild) - IPA validation script or Transporter preflight passed (signing, plist, forbidden modes)
- Privacy policy URL live; export compliance and age-rating questionnaires done in App Store Connect
- Internal TestFlight group dogfood ≥24h before external beta
| Stage | Tool / artifact | Catches |
|---|---|---|
| JS config | expo-doctor | Plugin/SDK drift before native work |
| iOS metadata | audit:ios, app.config | Usage strings, icons, privacy manifest gaps |
| Native truth | expo prebuild + Xcode archive | What Expo Go never exercises |
| Upload | Transporter + validate-ipa.sh | Invalid background modes, signing chain |
| Store | App Store Connect | Privacy URL, screenshots, encryption questionnaire |
Native modules that force prebuild in this stack: VisionCamera, WebRTC, App Check—plan rebuild time when you add any of them.
#Related reading
#On this site
| Post | Why |
|---|---|
| Lessons from building a mobile events social platform | Architecture spine — what you are shipping |
| Securing Firebase for a social mobile app | P0 rules before TestFlight |
| Building a Gemini AI backend with SSE | Native rebuild required for App Check — same class of work |
#References (curated)
EAS is the path I document for teammates; Apple’s TestFlight page is the checklist for compliance metadata that blocks external testers.
| Reference | Notes |
|---|---|
| Expo: Create a production build | When expo start is no longer the artifact you ship. |
| EAS Build | Signed IPA/AAB, credentials, and the profiles you actually archive. |
| Apple: TestFlight beta testing | External tester gates—privacy nutrition labels, export compliance. |
| Expo: App version and build numbers | Monotonic buildNumber—TestFlight rejects recycled values. |