πŸš€ ShipToStore

iOS publishing guide Β· 11 steps Β· ~3h 30m

πŸ› οΈ Prepare the app

Bundle ID, versions, icons, signing, and purpose strings β€” get these right once and every later upload just works.

Set a unique Bundle ID (reverse-DNS)

10 min

The Bundle ID is your app's permanent unique identifier, in reverse-DNS form: com.yourname.fittrack.

  • Use a domain you control (or com.<yourname>.<app> if you have no domain). Stick to letters, digits, dots, and hyphens; case-sensitive β€” pick lowercase and never vary it.
  • Where to set it:
    • Xcode: target β†’ General β†’ Identity β†’ Bundle Identifier.
    • Expo: expo.ios.bundleIdentifier in app.json.
    • Flutter: the PRODUCT_BUNDLE_IDENTIFIER in ios/Runner.xcodeproj (edit via Xcode's General tab).
    • RN bare: Xcode, same as native.
  • Registering: with automatic signing, Xcode registers the App ID for you; EAS does it during your first build. You can also register manually at developer.apple.com β†’ Identifiers.

⚠️ Pitfall

The Bundle ID cannot be changed after your first upload to App Store Connect β€” it's welded to the app record forever (the name can change; the bundle ID can't). Triple-check spelling and capitalization before uploading.

Set version and build number conventions

10 min

Two different numbers, two different jobs:

  • Version (CFBundleShortVersionString, e.g. 1.0.0) β€” user-visible, shown on the App Store. Must increase for each released version.
  • Build number (CFBundleVersion, e.g. 1, 2, 42) β€” must be unique and increasing for every single upload, even failed ones.

Where to set them:

  • Xcode / RN bare: target β†’ General β†’ Identity β†’ Version / Build.
  • Expo: expo.version and expo.ios.buildNumber in app.json β€” or set "autoIncrement": true in eas.json production profile and never think about it again.
  • Flutter: version: 1.0.0+1 in pubspec.yaml (the part after + is the build number).

Start at 1.0.0 / build 1 and increment the build number on every upload, no exceptions.

⚠️ Pitfall

Re-uploading with the same build number fails with ITMS-90061 Redundant Binary Upload. Builds that failed processing still consume their number β€” always bump before re-uploading.

Generate app icons in all required sizes

15 min

From your 1024Γ—1024 master (Phase 1):

  • Native / RN bare: in Assets.xcassets β†’ AppIcon, set the asset to Single Size and drop in the 1024 image β€” modern Xcode derives every size automatically.
  • Expo: point expo.icon (and optionally expo.ios.icon) at the 1024 PNG in app.json; the build pipeline generates the set.
  • Flutter: use the flutter_launcher_icons package:

Verify the result by building once and checking the home-screen icon on a simulator β€” a missing icon slot can fail upload validation (ITMS-90022).

dev_dependencies:
  flutter_launcher_icons: ^0.14.0

flutter_launcher_icons:
  ios: true
  image_path: assets/icon-1024.png
  remove_alpha_ios: true
dart run flutter_launcher_icons

Set the minimum deployment target deliberately

10 min

The deployment target is the oldest iOS version your app installs on. Don't just accept the template default:

  • Supporting the last two major iOS versions covers the overwhelming majority of active devices (Apple's adoption stats: iOS N + Nβˆ’1 is typically >90%).
  • Each older version you support is a test burden and an API ceiling (no newer SwiftUI/StoreKit features without if #available guards).
  • Where to set:
    • Xcode / RN bare: target β†’ General β†’ Minimum Deployments.
    • Expo: expo-build-properties plugin β†’ ios.deploymentTarget in app.json (defaults are sensible per SDK).
    • Flutter: platform :ios, '13.0' in ios/Podfile and the Xcode target setting β€” keep them in sync.
  • Check your dependencies: some pods/packages force a floor (e.g. a package requiring iOS 15 raises your minimum whether you like it or not).

Add purpose strings for every permission used

30 min

Every protected API your app β€” or any SDK you embed β€” touches needs a NS...UsageDescription key in Info.plist, e.g.:

  • NSCameraUsageDescription β€” camera
  • NSPhotoLibraryUsageDescription β€” photo library
  • NSLocationWhenInUseUsageDescription β€” location
  • NSMicrophoneUsageDescription β€” microphone
  • NSContactsUsageDescription β€” contacts

Write specific, user-benefit strings:

  • βœ… "We use the camera to scan receipts so you don't have to type them."
  • ❌ "Camera needed." (vague strings are a 5.1.1 rejection)

Where:

  • Xcode / RN bare: target β†’ Info tab.
  • Expo: expo.ios.infoPlist in app.json (or via each module's config plugin options).
  • Flutter: ios/Runner/Info.plist.

Audit your SDKs β€” image pickers, analytics, and ad SDKs often link camera/location frameworks you never call directly, and Apple's static scan doesn't care whose code it is.

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSCameraUsageDescription": "We use the camera to scan receipts so you don't have to type them."
      }
    }
  }
}

⚠️ Pitfall

Missing purpose strings cause automatic rejection during upload processing (ITMS-90683) β€” the build silently never appears in TestFlight and the only notice is an email. This is the single most common first-upload failure.

Enable Automatically Manage Signing in Xcode

15 min

Let Xcode handle certificates and provisioning profiles β€” manual signing is rarely worth it for a solo app.

  1. Xcode β†’ Settings β†’ Accounts β†’ + β†’ sign in with your developer Apple ID. Your team should appear.
  2. Open the project, select the app target β†’ Signing & Capabilities tab.
  3. Check Automatically manage signing and select your Team (the one with your Team ID, not 'Personal Team').
  4. Xcode registers the App ID, creates an Apple Development certificate, and generates profiles. The Apple Distribution certificate is created on first archive/distribute if missing.
  5. Resolve any red errors here now β€” signing errors at archive time are this same screen's problems, deferred.

Flutter users: do this inside ios/Runner.xcworkspace, on the Runner target.

⚠️ Pitfall

If you see "No signing certificate 'iOS Distribution' found", the cert's private key is on another Mac or the cert limit is hit β€” see the linked problem before creating yet another certificate.

React Native: pod install and a clean Release build

30 min

Get the native side of your RN app release-ready:

  1. Install pods after any native-dependency change:
  2. Hermes is the default JS engine on modern RN β€” leave it enabled unless you have a specific reason (smaller, faster startup). Check ios/Podfile.properties.json or your Podfile for hermes_enabled.
  3. Build the Release configuration locally before archiving β€” Debug-only bugs (dev-menu code, Metro dependency) surface here, not in review:
    • Xcode: Product β†’ Scheme β†’ Edit Scheme… β†’ Run β†’ Build Configuration β†’ Release, then build to a device.
  4. Confirm the JS bundle embeds correctly (Release builds run without Metro; if the app white-screens, the bundle step failed).
cd ios && pod install && cd ..
npx react-native run-ios --mode Release

Flutter: configure ios/Runner signing and release settings

25 min

Flutter's iOS shell lives in ios/ and is configured through Xcode:

  1. Open the workspace (not the project):
  2. Select the Runner target β†’ Signing & Capabilities β†’ enable Automatically manage signing, choose your Team, and confirm the Bundle Identifier matches what you chose earlier.
  3. In General, check Display Name, Version, and Build (these mirror pubspec.yaml's version: x.y.z+n).
  4. Review ios/Runner/Info.plist for purpose strings and ios/Podfile for the platform floor.
  5. Sanity-build a release to a device: flutter run --release.
open ios/Runner.xcworkspace
flutter run --release

Expo: install EAS CLI and log in

10 min

EAS (Expo Application Services) builds, signs, and submits your app in the cloud β€” no Mac required.

  1. Install the CLI globally and log in with your Expo account (create one at expo.dev if needed):
  2. EAS Build has a free tier with queue limits; paid plans speed up the queue. Check current limits at expo.dev/pricing.
  3. You'll also need your Apple Developer account credentials handy β€” EAS talks to Apple's APIs on your behalf to manage certificates and (later) create the App Store Connect app record.
npm i -g eas-cli
eas login
eas whoami

Expo: eas build:configure

10 min

Generate the EAS config:

  1. Run the configure command β€” it creates eas.json with development, preview, and production build profiles.
  2. Confirm expo.ios.bundleIdentifier is set in app.json (EAS will prompt if missing).
  3. Recommended eas.json production tweaks:
    • "autoIncrement": true β€” EAS bumps the build number for you on every build, killing the 'Redundant Binary Upload' class of errors.
  4. Commit eas.json to version control.
eas build:configure
{
  "build": {
    "production": {
      "autoIncrement": true
    }
  }
}

Expo: run your first eas build --platform ios

45 min
  1. Kick off a production build:
  2. First run, EAS asks to log into your Apple Developer account and offers to manage credentials β€” say yes and let EAS handle it. It will:
    • register the Bundle ID,
    • create/reuse a distribution certificate,
    • create the provisioning profile,
    • store everything encrypted on EAS servers (inspect anytime with eas credentials).
  3. The build runs in the cloud (typically 10–25 min depending on queue). You get a URL to watch logs and download the .ipa.
  4. Errors in native modules surface here for the first time β€” read the Xcode log section of failed builds; the JS logs are rarely the culprit.
eas build --platform ios --profile production
eas credentials

⚠️ Pitfall

"Distribution certificate limit reached": Apple caps active distribution certs per team. Revoke unused ones at developer.apple.com β†’ Certificates or via eas credentials (revoking does not break already-shipped apps), then re-run the build.

← Agreements, Tax & BankingCreate the App Store Connect record β†’

Track this interactively

Check off steps, skip what doesn't apply, and pick up where you left off.

Open the checklist β€” free