Introduction
It worked in Expo Go. That was the whole point of Expo Go. Somebody scanned a QR code, the app opened on a real phone, and the demo went well enough that the app is now a commitment. Then the release stopped. Either a library the business needs will not run, or a build fails with a native error nobody on the team can read, or an environment variable you definitely changed is still wrong in the installed app.
Here is the part worth reading before anything else. Your JavaScript is fine. Your screens, your navigation, your state, your business logic, almost none of it needs rewriting. What changes is the layer underneath the application code, the part that decides how a binary gets built, how configuration reaches it, and how releases go out. That layer barely existed while you were in Expo Go, because Expo was providing it for you.
Why does an Expo Go app stop working when it has to ship? Because Expo Go is a pre-built app with a fixed native surface, and production needs native code that surface does not contain. The fix is a development build, not a rewrite.
- You do not eject. That command is gone. Native projects are now generated on demand with
npx expo prebuild. - Native modules are the usual wall. Expo Go cannot load native code it was not compiled with. No flag fixes this.
- Environment variables are baked in at build time. Changing one and restarting the app does nothing.
- New Architecture has been the default since React Native 0.76. The cost lands in third-party dependencies, not in your code.
- Over-the-air updates cover JavaScript only. Native changes, new permissions and SDK upgrades all need a store build.
Everything technical below is taken from the current Expo and React Native documentation rather than from memory, because this tooling moves fast and a confident wrong version number would waste your afternoon. Where something depends on your specific setup, we say so instead of guessing.
One more thing, and it matters for how you read the rest. If an agency has looked at your Expo prototype and come back proposing a rebuild from scratch, be careful. That is almost never what the codebase needs. It is, however, a much larger engagement.
Why Can Expo Go Not Run Your App Any More?
Expo Go is an app. A real one, sitting on the store, that anybody can install. Expo compiled it, which means Expo decided at compile time exactly which native code it contains. When you scan that QR code, your JavaScript is being loaded into a container somebody else built.
That arrangement is brilliant right up until your product needs native code of its own. A payments SDK. A Bluetooth library for the hardware your company actually sells. Background location. A specific analytics package the marketing team has already signed for. Each of those ships compiled native code, and there is no mechanism to get that code into a binary that Expo controls and you do not.
So this is not a limitation you can raise a ticket about. It is the architecture doing exactly what it was designed to do. There is no configuration flag, no paid tier, and no workaround, and any answer you find suggesting otherwise is solving a different problem.
The replacement is a development build. Expo's own description is the clearest one: a development build is your own version of Expo Go. Same fast refresh, same developer tooling, except you compiled it, and it contains precisely the native libraries your project depends on. The expo-dev-client library provides the developer experience layer on top. You also get network request inspection and a launcher for switching between development servers, which matter more than they sound once several people are working on the app.
Expo states plainly that Expo Go is not intended for production deployment. It is worth taking that at face value. Expo Go is a development tool that happens to run your app, and treating it as a preview of your shipped product is what leads to the surprises in the next section.
Do You Actually Have to Eject From Expo?
No. And if you take one correction away from this page, make it this one, because the wrong answer here costs real money.
Ejecting used to be a one-way door. You ran a command, Expo generated the native iOS and Android projects, handed them to you, and from that moment you owned them forever. Every SDK upgrade afterwards became a manual merge into native directories that had drifted from anything Expo could regenerate. Teams were rightly frightened of it, and a lot of the anxiety around Expo in production traces directly back to that command.
It no longer exists. The current model is Continuous Native Generation, and the idea is a genuine improvement rather than a rename. Instead of creating native projects once and maintaining your changes to them for the life of the codebase, short-lived native projects are generated only when needed, from your app configuration and config plugins, by running npx expo prebuild. Native directories become build output rather than source you maintain. If they get into a bad state, you delete them and generate them again, which was never an option under ejecting.
The managed versus bare workflow split is deprecated framing as well. Prebuild is optional, so you can still maintain native directories by hand if you have a reason to, but it is no longer a fork in the road you commit to once.
The practical use of knowing this is diagnostic. If a consultant or an agency is telling you that you need to eject, or is pricing the work around the risk of ejecting, they are working from documentation that has been out of date for years. That tells you something useful about the rest of their advice before you have spent anything.
What Breaks Between Expo Go and a Real Production Build?
Every page on this topic lists these individually. Almost none of them explain that they are the same phenomenon, which is why they all arrive at once and feel like the project falling apart. Expo Go was hiding a production environment from you, kindly and deliberately. A release build stops hiding it.
Native modules that were never there. Covered above, and usually the first wall. It is also the one most likely to arrive as a business requirement rather than a technical choice, which is why it tends to land mid-sprint with no warning.
Configuration that was already baked in. Values you assumed were read at startup were compiled into the bundle. This one gets its own section next because of how often it ships a staging URL to production.
Permissions you never had to justify. Expo Go already held a broad set of permissions, so your code asking for the camera worked without you ever writing a justification. Your own binary requests only what you declare, and app review will ask why you want each one. A vague answer here is a common rejection.
Performance you were never really seeing. Development mode is slower, and your app runs in it the entire time you are in Expo Go. Teams sometimes discover their performance is better than they thought. Others discover a jank problem was being masked by a slow baseline they had normalised to.
Logs that stop appearing. In production mode console output no longer arrives in your terminal. If your debugging strategy was console logging, it silently stops working at the exact moment problems get harder, which is why real error reporting belongs on the pre-launch list rather than the someday list.
Deep links and navigation that behaved differently. Links routed through Expo Go's own scheme while you were developing. Your app has its own, and universal links need real association files hosted on a real domain. This tends to be found by a marketing campaign rather than by a test.
None of these are hard problems individually. They are, however, six things at once, usually landing in the week somebody promised the app would be in review, and each one is in a different discipline. That combination is what turns a two-week estimate into six.
Why Did Your Environment Variables Not Change?
You changed the value. You restarted the app. It is still wrong. Nothing is broken, and the explanation is worth understanding properly because the same misunderstanding causes a much more expensive version of this problem later.
EXPO_PUBLIC_ variables are inlined into the JavaScript bundle at build time. They are not looked up when the app starts. By the time the app is installed on a device, the literal value is already sitting inside the bundle, exactly as it was when that build was made. Editing an env file afterwards changes a file the installed app never reads.
So each environment needs its own build. Development, staging and production are three builds, not one build with three settings. That is a real change in how a team works, and it is the point where a proper build profile setup stops being optional.
The expensive version happens after launch. A wrong value in a shipped binary cannot be corrected by fixing the environment file, because the binary already contains the old one. Correcting it means a new build and a store submission, unless the change is purely JavaScript and can travel as an over-the-air update. That is the difference between a five minute fix and a two day one, decided entirely by a detail nobody knew about.
Worth stating too: the prefix is PUBLIC and it means it. Inlined values are readable by anyone who takes your bundle apart. No API secret, private key or admin token belongs there, whatever the environment. Anything that must stay secret belongs behind your own backend, which is a good architecture for other reasons anyway.
What Does the New Architecture Migration Actually Cost You?
Somebody has told you the app needs migrating to the New Architecture, and nobody can tell you what that means for the schedule. It is a fair question and it has a more reassuring answer than the phrase suggests.
React Native historically had JavaScript and native code talking across an asynchronous bridge, serialising messages in both directions. That bridge was the source of a whole category of performance problems, particularly anywhere data volume was high. The New Architecture replaces it with JSI, which lets JavaScript hold direct references to C++ objects and the reverse, removing the serialisation cost entirely. Fabric is the new renderer, TurboModules are native modules built on JSI, and together they enable things the bridge made impractical, including synchronous layout measurement and React 18 concurrent features like Suspense and Transitions.
On timing, the numbers that matter. The redesign began in 2018. It became available as an experimental opt-in in React Native 0.68, released on 30 March 2022. It has been the default since React Native 0.76. Meta has been running it in production apps at scale. So this is not a bleeding-edge migration, it is catching up with a default that has been standard for a while, and the ecosystem has had years to follow.
Where the cost actually lands. Almost never in your application code. Your components, screens and business logic are written against React and they mostly do not care what is underneath. The work is in third-party libraries that ship native code, and it is archaeology rather than engineering. Every native dependency needs checking. Most well-maintained ones have supported the New Architecture for a long time. The problems are the abandoned ones, the library that solved something awkward in 2021 and has not been touched since, and for those the options are a maintained replacement, a fork you now own, or dropping the feature.
Which means the effort scales with how many native dependencies you have and how well maintained they are, not with the size of your app. A large application with six mainstream native libraries is a straightforward migration. A small one with twenty, several of them unmaintained, is not. That is why the dependency audit belongs at the very start, before anyone commits to a date. It is cheap to run and it is the only step that can change the plan.
Can You Ship a Fix Without Waiting for App Review?
Some fixes, and knowing exactly which ones is the difference between a hotfix that goes out in minutes and one that waits on review while the problem stays live.
EAS Update is a cloud service serving over-the-air updates to apps using the expo-updates library. It updates the JavaScript, styling and image assets of an installed app between store submissions.
What travels over the air. Bug fixes and crashes in JavaScript. Copy, translations, styling and screen layouts. Rollouts to a percentage of users, which is genuinely useful for anything risky. Updates pushed from CI.
What requires a new build and a store submission. Any native code or native dependency change. Any newly requested permission, camera or location or anything else. Expo SDK upgrades. Anything at all that needs a new binary. The rule underneath is simple: over-the-air updates can change what your JavaScript does, never what your binary is.
The failure mode here is assuming the wrong side of that line under pressure. A team plans a fast rollback for a bad release, discovers the fix touches a native dependency, and loses two days they had already promised away. Establish which side each kind of change falls on before you need to know, and write down who is allowed to push an over-the-air update, because a mechanism that bypasses review is also a mechanism that bypasses your release process.
Who Should Not Migrate Off Expo Go?
Plenty of teams read a page like this, conclude they are behind, and go and adopt build infrastructure their product does not need. That is a real cost with no return, so here is the honest list of people who should close this tab.
Teams whose feature set is fully covered by the Expo SDK. Screens, navigation, network calls, camera, notifications, storage, maps. If that is your app and nothing on the roadmap needs a native library Expo has not already wrapped, you do not have this problem. You still need proper build profiles for store releases, but the migration this page describes is not yours to do.
Prototypes that are still prototypes. If you are validating whether anyone wants this, Expo Go is the correct tool and production infrastructure is a distraction from the only question that matters right now.
Internal tools that live on known devices. A demo app for your sales team does not need store submission, over-the-air update policy or credential management.
Teams already shipping without friction. If releases go out and nobody dreads them, your setup is working. An upgrade you do not need is a risk you did not have to take.
And a fourth group who should be honest with themselves in the other direction. If your app needs native modules, has real users, handles payments or personal data, and nobody on the team has shipped to both stores before, the migration is not optional and estimating it as a sprint is how the date slips twice. That is a scheduling problem more than an engineering one, and it is fixable if you see it early.
How Do You Know Whether Your Team Can Carry This?
This is the actual decision, and it is not about how good your engineers are. It is about whether anyone has done this specific thing before, because the difficulty here is almost entirely unfamiliarity rather than complexity.
Has anyone shipped to both stores before? Not written an app. Shipped one. Apple certificates, provisioning profiles, Play signing keys, review metadata, the privacy questionnaire. Every one of those is straightforward the second time and a maze the first, and none of it is React Native knowledge.
Can somebody read a native build failure? When a build breaks you get a Gradle or Xcode error, not a JavaScript stack trace. A team that has never seen these can lose days to something a mobile engineer recognises immediately.
Does a release depend on one laptop? If credentials and signing live on one machine and one person, you do not have a release process, you have a single point of failure that happens to be employed. Fixing that is a good idea regardless of who does the migration.
Has anybody audited the native dependencies yet? If not, nobody currently knows how big this is. Any estimate given before that audit is a guess wearing a number.
Two or more no answers does not mean you need outside help permanently. It means the first release will take substantially longer than whatever has been estimated, and it should be planned that way rather than discovered. The teams that get hurt here are not the ones that lacked the skills, they are the ones that committed to a date before finding out what they were committing to.
What the Path Actually Looks Like
In order, and the order is the part that matters. Doing these in the wrong sequence is where the schedule goes.
One. Audit the native dependencies. Every library shipping native code, checked against New Architecture support and current maintenance. Cheap, fast, and the only step that can change everything downstream. It goes first for that reason alone.
Two. Move to a development build. Get the team off Expo Go and onto a binary containing your real native dependencies, so that from here on you are testing something shaped like what ships. Everything after this is easier once the thing on the phone matches reality.
Three. Split configuration per build profile. Separate values for development, staging and production, with production ones held somewhere that is not a developer's machine. This is what stops a staging URL reaching a shipped binary.
Four. Set up build profiles and credentials properly, once. Signing, certificates and store credentials in a place the whole team can build from. Tedious, and it is the step that removes the single point of failure.
Five. Wire over-the-air updates with a written policy. Not just the mechanism, but the rules. What may go out this way, who may send it, and how it gets rolled back. A channel that skips review needs a process precisely because it skips review.
Six. First store submission. Budget more time than the code suggests. First submissions are usually delayed by privacy declarations, permission justifications and screenshots rather than anything technical.
An honest admission about that step. We have been caught by it too. Not by the code, which was ready, but by a permission string that described what the app did in language a reviewer read as something else, and by a privacy declaration that took three attempts to get right. The engineering was done a week before the app went live. If your plan treats submission as a formality at the end of the last sprint, move it earlier and give it a name and an owner.
Seven. The boring part. A repeatable release cadence, crash reporting somebody actually reads, and a rehearsed rollback. This is the difference between having shipped once and being able to ship whenever you want to.
We have taken products through this path and through the harder versions of it, including systems where the failure modes are considerably less forgiving than a delayed release. Our team has shipped an exam platform handling over 10 million requests a minute and an EdTech platform serving more than 250,000 daily users, and Pixytan, which tracks over 30,000 vehicles. More than 50 products have gone out of this studio, and we hold Top Rated Plus at 4.9 on Upwork. The reason that is relevant here is narrow and specific: none of the steps above are difficult, and all of them are unfamiliar the first time.









