How RapidNative Resolves Expo Package Versions Automatically
(156 chars): How RapidNative automatically resolves Expo package versions using SDK-aware pins, bundledNativeModules, and live registry fallback. Zero version mismatches.
By Suraj Ahmed
3rd Oct 2026
Last updated: 3rd Oct 2026
Every React Native developer has lost a day to the same bug. The app runs on your machine. You add a package — expo-camera, react-native-purchases, something ordinary — and the preview goes blank. No error. No stack trace. Just a white screen on the phone and a cold feeling that something, somewhere, is subtly off by a minor version.
Expo dependency management is one of those domains where "it works on my machine" is actually a statement about which precise set of versions landed in node_modules the first time you ran npm install. Change one SDK number, and a cascade of peer-dependency bumps follows. Miss a bundledNativeModules.json pin, and Metro will happily serve a bundle that references an API that doesn't exist in the runtime shipped with the current Expo SDK.
A single wrong minor version can produce a blank preview with no error — Photo by Luca Bravo on Unsplash
RapidNative generates thousands of React Native apps a week from natural-language prompts. Our AI agent reads a user's intent — "add barcode scanning to the checkout screen" — writes code, and installs the packages it needs. If version resolution were left to chance, the preview would break constantly. It almost never does, and this post is about why.
This is a technical post about three things: the resolution precedence our agent walks before writing a single line into package.json, the SDK-aware pin map that overrides the npm registry when it has to, and the sync path from "AI typed npm install" to a hot-reloaded preview on your phone.
Why Expo Dependency Management is Different
Dependency management in a generic Node project is a solved problem. You run npm install react@19, npm finds the latest 19.x on the registry, writes the lockfile, and you move on. The runtime is whatever node happens to be installed.
Expo projects do not work this way. The runtime is a native binary — Expo Go on your phone, or a custom dev client — compiled against a specific version of React Native, which is itself compiled against a specific version of Hermes, which exposes a specific JavaScript API surface. The native modules bundled into that binary have JavaScript shims in packages like expo-camera and expo-location, and those shims only call APIs that exist in the matching SDK.
If you install expo-camera@16.x into an SDK 57 project, the JS calls ExpoCamera.getAvailableCameraTypesAsync(). The native binary on the phone, built for SDK 57, exposes getCameraPermissionsAsync(). The result is a null return and a blank screen.
The Expo team publishes bundledNativeModules.json with every SDK — a manifest of which exact minor of each expo-* package the shipped binary supports. The npx expo install CLI reads it. But most AI code generators do not. They read package names off StackOverflow answers written for SDK 49, write "expo-camera": "latest", and hope.
The
npx expo install CLI respects bundledNativeModules.json — but only if your tooling calls it — Photo by Vishnu Mohanan on Unsplash
RapidNative does not hope. We built a three-layer resolution precedence that runs before any version string gets written to disk.
The Three-Layer Resolution Precedence
When the agent writes npm install expo-location, that command does not shell out to the real npm. It routes through resolveDependency() in our package-commands module, which walks three sources in order.
Layer 1 — RapidNative pins. A hand-curated PINNED_DEPENDENCY_VERSIONS map, maintained per SDK, that overrides everything downstream. These are the versions we've battle-tested across our generated-app fleet. A pin wins unconditionally — if the registry has a newer version and the SDK manifest says it's compatible, we still write the pin, because the pin exists for a reason (usually a specific regression we've seen on real devices).
Layer 2 — Expo SDK bundledNativeModules. If the package isn't pinned, we check whether it's in the current SDK's bundled modules manifest. For SDK 57, that's expo-camera: ~57.0.4, expo-location: ~57.0.15, expo-router: ~57.0.18, and dozens more. Match found, write the pin.
Layer 3 — Live npm registry. For everything else — packages like zustand, date-fns, react-hook-form that have no Expo-specific coupling — we query the registry live and pick the highest version matching the agent's requested range. Semver works normally here.
The crucial detail: the agent sees the resolution reason in its tool output. If it asks for expo-camera@16 and we pin it to ~57.0.4, the response is explicit: "Resolved expo-camera to ~57.0.4 (Expo SDK 57 bundled version — overrode requested 16)". The model learns, in-context, that its training-data instinct was wrong for this project.
This is why dependency drift is self-correcting rather than catastrophic. In a vacuum, LLMs will reach for the version string they saw most often in their training data — usually something 12 to 18 months out of date. The resolution function catches it, corrects it, and tells the agent what happened. Next time the agent writes a camera flow in the same session, it reaches for the right version on its own.
The SDK-Aware Pinning Map
The pins live in postProcessor.ts as two separate maps keyed by SDK version. This matters because SDK upgrades do not happen fleet-wide overnight — on any given day, we have apps running on SDK 54 and apps running on SDK 57, and the resolver has to serve both.
// Shape of the pin map (conceptual)
PINNED_DEPENDENCY_VERSIONS = {
54: {
"expo-location": "~19.0.8",
"expo-camera": "~17.0.10",
"react-native-purchases": "~10.6.0",
"@sentry/react-native": "~8.24.0",
},
57: {
"expo-location": "~57.0.15",
"expo-camera": "~57.0.4",
"react-native-purchases": "~10.6.0", // SDK-agnostic
"@sentry/react-native": "~8.24.0", // SDK-agnostic
},
};
Two things worth noticing. First, the expo-* versions jump dramatically between SDK 54 and SDK 57 because Expo moved its ecosystem to a unified ~57.x versioning scheme — every official package now tracks the SDK's major. Second, the non-Expo packages (react-native-purchases, @sentry/react-native) carry the same pin across SDK generations. These are vendor libraries whose compatibility surface is React Native itself, not Expo, and we've tested them against both RN versions.
A SDK bump cascades into dozens of package pin updates — Photo by Alexandre Debiève on Unsplash
The agent reads the current project's expo version to decide which pin set applies. If a project is still on SDK 54, we write the SDK 54 pins even if SDK 57 is "newer." Correctness beats recency.
The Banned Packages List
Three packages the agent is never allowed to install, regardless of what the user asks:
react-native-iap— This library predates RevenueCat and does not handle receipt validation, server-side entitlements, or the current App Store subscription flows cleanly. We route all IAP work throughreact-native-purchasesinstead. If the model asks forreact-native-iap, it gets a rejection with the reason and a pointer to the right package.react-native-linear-gradient— Deprecated for mobile use because it has no clean web implementation, which breaks the fullstack-supabase template's web export. The replacement isexpo-linear-gradient, which the Expo team maintains with cross-platform parity.expo-av— Removed entirely in SDK 57. The agent is told, with the error, to useexpo-audioorexpo-videodepending on media type.
The ban list is tiny on purpose. We do not maintain an allowlist of "approved" packages — that would collapse the agent's ability to add anything we hadn't thought of in advance. Instead, we curate at two edges: the ban list stops specific known-bad choices, and the SDK pin map locks the versions of the packages we know people will reach for. Everything in the middle flows through live registry resolution.
The expo install --check Safety Net
After the agent finishes a generation, postProcessor.ts runs npx expo install --check against the final package.json. This is Expo's official audit — it compares every declared dependency to the SDK's bundledNativeModules.json and flags mismatches.
In practice, this almost never catches anything the resolution precedence missed. But "almost never" is doing work. The one scenario where it does catch something is when the user manually edits package.json in the editor (we expose the file; some technical users will tweak it), and the subsequent agent run inherits a stale version. The --check pass catches it, the --fix pass corrects it, and the resulting bundle rebuilds cleanly.
A related invariant: react and react-dom must match. SDK 57 ships with React 19.2.3, and both packages must declare that exact version — React Server Components and the reconciliation code paths are the same binary in memory, and a 19.2.0 vs 19.2.3 mismatch produces warnings that cascade into render failures on specific suspense boundaries. We enforce the match in a post-process step called ensureReactDomMatchesReact().
From npm install to Live Preview
Here's the full path, from the agent typing npm install expo-location to the package being available in the running preview on your phone.
A dependency change propagates through four systems before the preview reloads — Photo by Rodion Kutsaiev on Unsplash
Step 1 — Agent call. The agent emits npm install expo-location as a tool call. Our custom npm implementation intercepts it. The real npm never runs; node_modules is never written anywhere.
Step 2 — Version resolution. resolveDependency() walks the three-layer precedence (pins → bundledNativeModules → registry) and picks ~57.0.15 for SDK 57 projects. The decision and its reason are returned to the agent.
Step 3 — Virtual file system write. The resolved pin is written to mobile/package.json in the project's virtual filesystem — the same VFS that backs the editor and feeds every subsequent agent tool call. This is a database write, not a disk write; the project's files live in Supabase and never touch a Node machine.
Step 4 — Orchd sync. orchd-sync.ts detects the package.json change via a file watcher that fires on VFS writes. It calls putWorkloadFileRaw(workloadId, "mobile/package.json", content) against the cloud workload — a lightweight container running on rapidnative.app that hosts the actual Metro dev server for this project.
Step 5 — Debounced restart. The sync layer schedules a workload restart with a 3-second debounce window. Burst writes — common when the agent is editing multiple files in rapid succession — collapse into a single restart rather than one per file. This matters because Metro can take 30 to 90 seconds to rebuild a bundle from scratch on a cold start, and triggering that restart on every file write would make live preview unusable.
Step 6 — Metro rebuild. Inside the workload, rnrun restart triggers Metro to rebuild its dependency graph. For a pure JavaScript package addition, this is fast — a few seconds. For a native-module addition, Metro has to reread bundledNativeModules.json and rebuild the web export as well, which takes longer but still finishes inside the debounce window plus a reasonable wait.
Step 7 — Phone reload. Expo Go on your device, or the embedded preview in the editor iframe, receives a reload signal from the workload. The new bundle loads with expo-location available at the resolved version. The user sees their camera flow work.
The whole chain, start to finish, takes 5 to 15 seconds for a JS-only package and 20 to 40 seconds when a native module requires a Metro-level rebuild. Our live-preview architecture post goes deeper into how the preview itself works.
No Lockfiles — and Why That's Fine
Generated projects do not ship with a package-lock.json, yarn.lock, or pnpm-lock.yaml. This surprises people, so it's worth explaining.
The preview environment uses browser-metro, Expo's in-browser bundler, which resolves every npm package live at bundle time from the package server. There is no node_modules folder in the preview — nothing to lock. The pins in package.json are the lockfile, in effect, because every version is pinned to an exact minor via tilde ranges.
When a user eventually exports the project for an EAS build to produce an actual IPA or APK, the lockfile is generated at that moment, per build, and travels with the build artifact. It never has to live in the template.
Lockfiles are generated at build time, not stored in the template — Photo by Taylor Vick on Unsplash
The practical consequence: git clone + npm install on a RapidNative project will produce the same working app on any machine, because the pins are tight and the registry serves them deterministically. No lockfile churn in diffs, no stale lockfiles after an SDK upgrade.
Fleet Migrations: The SDK 54 → 57 Upgrade
The hardest problem in dependency management is not writing good pins today. It's rewriting all your pins when a new SDK ships. In September 2026, we ran a fleet migration from Expo SDK 54 to SDK 57 — React Native 0.74 to 0.86, React 18 to 19.2.3, every expo-* package bumped two majors.
The template changes were straightforward: a new PINNED_DEPENDENCY_VERSIONS key, a new scaffold/mobile/package.json with SDK 57 pins, and a one-time migration path. The hard part was the existing projects — tens of thousands of them, each with their own set of installed packages, user edits, and in-flight generations.
We built an admin-only batch API (/admin/update-source-files) that walks the project fleet, applies the SDK upgrade transforms per project, and rebuilds the orchd workload. Projects that users haven't touched in weeks get upgraded in the background. Active projects get an in-editor prompt and upgrade on next open. The SDK 54 pins stay in the resolver for projects that haven't migrated yet, so an agent run on a legacy project still writes the right versions.
This is the invariant that makes fleet migrations survivable: the resolver and the template are both SDK-aware, and they agree on which pins apply per project. One source of truth, two consumers.
Takeaways for Anyone Building Expo Apps
If you're building Expo apps by hand, three things from our system are worth borrowing even without AI:
- Treat
bundledNativeModules.jsonas a hard constraint, not a suggestion. Always usenpx expo installfor Expo packages, never rawnpm install. The CLI reads the manifest; raw npm does not. - Maintain a tiny banned list. You don't need an allowlist — you need to know the three or four packages in your stack that are deprecated and have better replacements. Document it somewhere the whole team sees.
- Pin tightly, not loosely.
~57.0.15is better than^57.0.0. The former locks the patch range; the latter can quietly drift across minor versions during an npm install and reintroduce bugs you already fixed.
Dependency hygiene is a team practice, not just an AI practice — Photo by Mapbox on Unsplash
For teams using RapidNative, all of this happens for free. You describe what you want the app to do; the agent writes the code and the correct dependencies at the correct versions. If you ever need to inspect what landed in package.json, the file is right there in the editor, pinned exactly the way our resolver chose.
Try It Yourself
If you want to see this system in action, start a new project and prompt the agent for a feature that needs a specific package — "add barcode scanning," "add push notifications," "add in-app purchases." Open the editor's file browser, look at mobile/package.json, and you'll see the exact pin the resolver chose, down to the patch version. Then look at the live preview on your phone and watch the feature work on the first try.
The best dependency manager is the one you never have to think about. We spent a lot of time making ours boring on purpose.
Related reads: why we chose Expo over bare React Native, inside RapidNative's export pipeline, and how our browser bundler delivers instant preview. For plan details, see RapidNative pricing.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.