Shipping an Expo app to the App Store: the landmines nobody warns you about A developer shipped an iOS app called Add to Calendar: AI Events, built with Expo SDK 52 and React Native, and documented the hidden pitfalls of the App Store submission process. The hardest part was configuring the share extension to avoid build failures, requiring a specific combination of plugin settings and a hand-written appExtensions block. The developer also used patch-package to backport a bug fix without upgrading the SDK and adopted EAS Metadata to manage App Store listing as code, catching corrupted line breaks in the live description. I recently shipped Add to Calendar: AI Events https://apps.apple.com/app/id6772644308 — an iOS app that turns screenshots and text into calendar events — built with Expo SDK 52 and React Native's new architecture. The code is open source MIT https://github.com/ryoshumei/add-to-calendar-rn . The happy path in the Expo docs is genuinely good. This post is about everything that isn't on the happy path: the things that cost me hours, in the order they'll probably cost you hours too. The single hardest feature to ship was also the app's core flow: share a screenshot from any app → extension hands it to the main app → AI extracts the event. I used expo-share-intent https://github.com/achorein/expo-share-intent , which is excellent, but a share extension is a Three things have to agree with each other or the build dies: // app.json abridged { "plugins": "expo-share-intent", { "disableExperimental": true, // ← don't let the plugin manage appExtensions "iosActivationRules": { "NSExtensionActivationSupportsImageWithMaxCount": 1 }, "iosAppGroupIdentifier": "group.com.addtocalendar.rn" } , "extra": { "eas": { "build": { "experimental": { "ios": { "appExtensions": { // ← you declare the target yourself, once "targetName": "AddtoCalendarShare", "bundleIdentifier": "com.addtocalendar.rn.share-extension", "entitlements": { "com.apple.security.application-groups": "group.com.addtocalendar.rn" } } } } } } } } The landmine: if the plugin also injects an appExtensions entry the default , EAS sees a duplicate and the build fails at the "Read app config" step — before compiling anything, with an error that says nothing about share extensions. disableExperimental: true plus exactly one hand-written appExtensions block was the stable combination. Once it works, commit it and never touch it. Also budget a second provisioning profile and bundle ID .share-extension — EAS handles the signing, but App Store Connect will show the extension as its own "app" in some screens, which is normal and alarming in that order. patch-package is not a code smell on mobile — it's a release valve Two weeks after launch, users shipped me a bug: sharing a screenshot from the iOS markup editor the pencil icon after you take a screenshot crashed the share extension. The fix existed upstream in expo-share-intent v4 — but v4 required Expo SDK 53, and I was on 52 with a working, submitted build. Upgrading an entire SDK to ship a five-line fix is a bad trade the week after launch. Instead: hand-edit node modules/expo-share-intent/… npx patch-package expo-share-intent git add patches/ patch-package re-applies the diff on every npm install via postinstall . The backported fix shipped as v1.0.2 days later, and the SDK upgrade stayed a calm, separate decision. On web I'd call vendoring a dependency a smell; on mobile, where a "dependency upgrade" can mean re-testing every native module, it's how you ship a bug fix this week instead of next month. App Store Connect's web forms are where listing quality goes to die — you can't diff them, review them, or restore them. EAS Metadata fixes this and almost nobody uses it: eas metadata:pull download the live listing into store.config.json edit store.config.json: description, keywords, release notes, screenshot sets eas metadata:push upload — creates the new version's metadata in ASC My store.config.json and screenshots are committed next to the code. Release notes are written in the same PR as the feature. When I pulled the live listing for the first time, I discovered the description had corrupted line breaks from an earlier copy-paste into the web form — it had been live like that for weeks. Listing-as-code caught what eyeballing the form never did. Two caveats so you don't over-trust it: metadata:push does not attach a build or press "Submit for Review" — those final clicks are still manual or fastlane/ASC API if you want to script everything . expo-store-review wraps SKStoreReviewController . Every guide says "throttle it." Fewer mention the two things that actually bit me: It silently does nothing on TestFlight. By design, Apple suppresses the dialog there. If you're smoke-testing "does my rating prompt fire?", you must test a release build on a simulator/device outside TestFlight, or you'll ship believing it's broken or worse, that it's not, when it is . It will collide with your own dialogs. My prompt fires after a successful "event added" alert. Fire both and iOS will drop one on the floor. The fix is sequencing, not timing hacks — ask only after the user dismisses your alert: Alert.alert 'Added', "${event.title}" added to your calendar. , { text: 'OK', onPress: = void recordSuccessfulAddAndMaybeAskForReview }, ; The throttle itself: require ≥2 successful adds proven value , ask at most once per app version, persist both in AsyncStorage, and never let the review path throw into your core flow. Apple additionally caps the system prompt at ~3 shows per year — treat requestReview as a suggestion, not a call you own. TextInput + a submit button = a keyboard you can't dismiss A classic that every RN form eventually hits: a multiline input has no "done" affordance, the keyboard covers your primary button, and iOS users — who expect tap-outside-to-dismiss from native apps — get stuck. Three props, all needed: