{"slug": "shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about", "title": "Shipping an Expo app to the App Store: the landmines nobody warns you about", "summary": "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.", "body_md": "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).\n\nThe 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.\n\nThe 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\n\nThree things have to agree with each other or the build dies:\n\n```\n// app.json (abridged)\n{\n  \"plugins\": [\n    [\"expo-share-intent\", {\n      \"disableExperimental\": true,           // ← don't let the plugin manage appExtensions\n      \"iosActivationRules\": { \"NSExtensionActivationSupportsImageWithMaxCount\": 1 },\n      \"iosAppGroupIdentifier\": \"group.com.addtocalendar.rn\"\n    }]\n  ],\n  \"extra\": {\n    \"eas\": {\n      \"build\": {\n        \"experimental\": {\n          \"ios\": {\n            \"appExtensions\": [{               // ← you declare the target yourself, once\n              \"targetName\": \"AddtoCalendarShare\",\n              \"bundleIdentifier\": \"com.addtocalendar.rn.share-extension\",\n              \"entitlements\": {\n                \"com.apple.security.application-groups\": [\"group.com.addtocalendar.rn\"]\n              }\n            }]\n          }\n        }\n      }\n    }\n  }\n}\n```\n\nThe landmine: if the plugin *also* injects an `appExtensions`\n\nentry (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`\n\nplus exactly one hand-written `appExtensions`\n\nblock was the stable combination. Once it works, commit it and never touch it.\n\nAlso budget a second provisioning profile and bundle ID (`.share-extension`\n\n) — 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.\n\n`patch-package`\n\nis not a code smell on mobile — it's a release valve\nTwo 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`\n\nv4 — but v4 required Expo SDK 53, and I was on 52 with a working, submitted build.\n\nUpgrading an entire SDK to ship a five-line fix is a bad trade the week after launch. Instead:\n\n```\n# hand-edit node_modules/expo-share-intent/…\nnpx patch-package expo-share-intent\ngit add patches/\n```\n\n`patch-package`\n\nre-applies the diff on every `npm install`\n\n(via `postinstall`\n\n). 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.\n\nApp 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:\n\n```\neas metadata:pull    # download the live listing into store.config.json\n# edit store.config.json: description, keywords, release notes, screenshot sets\neas metadata:push    # upload — creates the new version's metadata in ASC\n```\n\nMy `store.config.json`\n\nand 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.\n\nTwo caveats so you don't over-trust it: `metadata:push`\n\ndoes **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).\n\n`expo-store-review`\n\nwraps `SKStoreReviewController`\n\n. Every guide says \"throttle it.\" Fewer mention the two things that actually bit me:\n\n**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).\n\n**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:\n\n```\nAlert.alert('Added', `\"${event.title}\" added to your calendar.`, [\n  { text: 'OK', onPress: () => void recordSuccessfulAddAndMaybeAskForReview() },\n]);\n```\n\nThe 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()`\n\nas a suggestion, not a call you own.\n\n`TextInput`\n\n+ a submit button = a keyboard you can't dismiss\nA 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:\n\n```\n<TextInput\n  multiline\n  returnKeyType=\"done\"        // Return key says Done…\n  submitBehavior=\"blurAndSubmit\"  // …and dismisses instead of inserting \\n\n/>\n// on the enclosing ScrollView:\nkeyboardDismissMode=\"on-drag\"     // scrolling tucks the keyboard away\nkeyboardShouldPersistTaps=\"handled\"\n```\n\nThe trade-off is honest: Return no longer inserts newlines. For a paste-mostly input that's right; pasted text keeps its line breaks anyway.\n\nIf you build semantic colors (`label`\n\n, `separator`\n\n, grouped backgrounds) into a theme hook from day one, dark mode is nearly free — except for modals. iOS grouped backgrounds are **pure black** in dark mode, and a modal sheet rendered in pure black on a pure-black screen has no visible edge. Native apps solve this with *elevated* variants; do the same:\n\n``` js\n// in the modal screen only\nconst theme = {\n  ...baseTheme,\n  groupedBackground: baseTheme.elevatedGroupedBackground,\n  card: baseTheme.elevatedCard,\n};\n```\n\nOne remap at the top of the modal, and the sheet reads as a sheet again.\n\nThe app has a bring-your-own-OpenAI-key mode. Two implementation decisions made App Review painless and became the listing's selling point:\n\n`expo-secure-store`\n\n, is sent `api.openai.com`\n\n, and never touches my server. That sentence goes verbatim into the privacy labels, the reviewer notes, and the description — and it's true, which is what makes Guideline 2.3 (accurate metadata) easy.One more pre-flight item that surprises people: `ITSAppUsesNonExemptEncryption: false`\n\nin `Info.plist`\n\nsaves you an export-compliance question on every single upload.\n\nEvery one of these was discoverable only by shipping. The gap between \"runs in Expo Go\" and \"approved on the App Store\" is real but bounded — roughly: one build-config fight (share extension), one process discovery (metadata as code), and a handful of platform behaviors (review prompt, keyboard, dark mode) that each cost an afternoon.\n\nIf you're one `eas submit`\n\naway and hesitating: the reviewer is not your enemy, the checklist is finite, and the second submission takes a tenth the time of the first.\n\n*The app: Add to Calendar: AI Events — screenshots/text → calendar events. Source: github.com/ryoshumei/add-to-calendar-rn. Questions about any of these landmines welcome in the comments.*", "url": "https://wpnews.pro/news/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about", "canonical_source": "https://dev.to/day_b3fa2204948ded3059f6f/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about-2b4o", "published_at": "2026-07-31 20:14:43+00:00", "updated_at": "2026-07-31 20:43:24.132884+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Add to Calendar: AI Events", "Expo", "React Native", "EAS", "App Store Connect", "expo-share-intent", "patch-package"], "alternates": {"html": "https://wpnews.pro/news/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about", "markdown": "https://wpnews.pro/news/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about.md", "text": "https://wpnews.pro/news/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about.txt", "jsonld": "https://wpnews.pro/news/shipping-an-expo-app-to-the-app-store-the-landmines-nobody-warns-you-about.jsonld"}}