6 min readCase study

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

GateToolWhat it catches
Config healthexpo-doctorPlugin mismatches, SDK drift
iOS-specificaudit:iosPermissions, icons, privacy manifest gaps
Native truthexpo prebuild + Xcode archiveWhat Expo Go never exercises
UploadTransporter + validation scriptInvalid UIBackgroundModes, signing
StoreApp Store ConnectPrivacy 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:

  1. expo prebuild to materialize ios/
  2. Inspect Info.plist with plutil or Xcode
  3. Correct modes in app.config / plugin config
  4. 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)

Bash
eas build --platform ios --profile production --local
# or
npx expo prebuild --platform ios
# open ios/*.xcworkspace in Xcode → Archive → Distribute

Local 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

RequirementWhy reviewers care
Privacy policy URLMust load; social apps get scrutinized
Export complianceEncryption usage questionnaire
Age rating questionnaireUser-generated content triggers higher scrutiny
Screenshots per device class6.7" and 5.5" minimum set
Subtitle + keywordsDiscovery, 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)

  1. P0 security rules deployed
  2. expo-doctor clean
  3. audit:ios clean
  4. Production profile build succeeds locally at least once
  5. Transporter validation passes
  6. Privacy policy live
  7. 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:

CheckWhat it catches
plutil -lint Info.plistMalformed plist syntax
Required keys (CFBundleIdentifier, version, build)Missing metadata
UIBackgroundModes allowlistInvalid values like background-processing typos
codesign -dvUnsigned 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-doctor clean; project audit:ios (or equivalent plist walk) clean
  • At least one local production archive or eas build --local when native modules are in play
  • UIBackgroundModes values match Apple’s allowlist (fix in app.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
StageTool / artifactCatches
JS configexpo-doctorPlugin/SDK drift before native work
iOS metadataaudit:ios, app.configUsage strings, icons, privacy manifest gaps
Native truthexpo prebuild + Xcode archiveWhat Expo Go never exercises
UploadTransporter + validate-ipa.shInvalid background modes, signing chain
StoreApp Store ConnectPrivacy 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.


#On this site

PostWhy
Lessons from building a mobile events social platformArchitecture spine — what you are shipping
Securing Firebase for a social mobile appP0 rules before TestFlight
Building a Gemini AI backend with SSENative 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.

ReferenceNotes
Expo: Create a production buildWhen expo start is no longer the artifact you ship.
EAS BuildSigned IPA/AAB, credentials, and the profiles you actually archive.
Apple: TestFlight beta testingExternal tester gates—privacy nutrition labels, export compliance.
Expo: App version and build numbersMonotonic buildNumber—TestFlight rejects recycled values.