In an Expo Router app, fetch('/api/waitlist') works in development but fails in the native release build; why, and how do you fix it?
answer
- a phone has no page origin
- dev server answers in development
- deploy the server bundle
- expo-router plugin origin
- EXPO_UNSTABLE_DEPLOY_SERVER automates it
basics
~20 sIn development relative URLs resolve to the Expo dev server, which runs API routes. A release build has no dev server, so the server must be deployed and its URL set as the expo-router plugin's origin before building.
solid answer
~40 sOn the web, `/api/waitlist` resolves against the page's origin, but a native app has none. In development Expo Router points relative requests at the dev server from `npx expo start`, which bundles and runs `+api.ts` files on demand, even without `web.output: 'server'`. A release build has no dev server, so the fix is to set `web.output` to `server`, run `npx expo export --platform web`, deploy the server with `eas deploy` or an `expo-server` adapter, set the `origin` option of the `expo-router` plugin to that HTTPS URL, and rebuild. The alpha `EXPO_UNSTABLE_DEPLOY_SERVER=1` flag automates a versioned deploy during EAS Build. The deployed server also needs its secrets configured, because it does not read `.env` files.
code
bash · 6 linesnpx expo export --platform web
eas deploy
# local check of the release path against a locally served export
npx expo serve
EXPO_NO_DEPLOY=1 npx expo run:ios --configuration Releasego deeper
Recall that API routes need a running server and that the dev server provides one only during development.
Explain why a relative URL has no origin on a device, what the expo-router plugin origin does, and the export-then-deploy steps.
Diagnose it as a release-only failure, reproduce it locally with expo serve and a release build, and check server environment variables when it then returns 500.
Plan the contract between binaries and server: versioned deployments, per-environment origins, and how long old app versions must be served.
## The symptom A team adds `src/app/api/waitlist+api.ts` to an Expo Router app. In development everything works: the web page and the iOS simulator both call `fetch('/api/waitlist')` and get a response. Then a release build of the native app ships, and the same call fails. Nothing is wrong with the handler. The problem is **what a relative URL means on a phone**. ## Why development hides the problem In a browser, `/api/waitlist` is resolved against the page's own origin, the site that served the HTML. A native app has no page origin. Expo Router's server features are built on native implementations of `window.location` and `fetch` that point at a server: 1. **In development**, that server is the **Expo dev server** started by `npx expo start`. It bundles and executes API routes on demand, so relative requests succeed. It even serves them when `web.output` is not `server`, printing only a warning. 2. **In a release build**, there is no dev server. Relative URLs resolve against the **`origin`** configured for the `expo-router` config plugin. If no origin was configured and no server was deployed, the request has nowhere to go. So the development loop proves the handler works; it does not prove there is a production server or that the app knows where it is. ## The fix, step by step 1. **Set `web.output` to `server`**, so the export produces a server bundle in `dist/server`. 2. **Export and deploy the server**: `npx expo export --platform web`, then deploy to EAS Hosting with `eas deploy` or to another host through an `expo-server` adapter. 3. **Point the native app at it**: set the `origin` option of the `expo-router` plugin in the app config to the deployed HTTPS URL, then rebuild, because the value is baked into the build. 4. **Provide the server's environment**: the deployed server does not read `.env` files; secrets such as the waitlist service key must be configured on the host. ```json { "expo": { "web": { "output": "server" }, "plugins": [["expo-router", { "origin": "https://example.com" }]] } } ``` ## Automating the origin Expo has an **alpha** path that deploys the server during the native build: with `EXPO_UNSTABLE_DEPLOY_SERVER=1` set, an EAS Build deploys a versioned server to EAS Hosting and writes the generated origin into the build. Its documented limits matter in an interview answer: - `origin` must **not** be set manually in the app config. - It does not support a dynamic `app.config.js` or `app.config.ts` yet. - `EXPO_NO_DEPLOY=1` skips it, and deployment logs go to `.expo/logs/deploy.log`. The benefit is **versioning**: each native build talks to the server deployed alongside it, so an old binary in users' hands keeps calling a compatible API. ## Testing the release path locally You can reproduce the production setup without shipping: 1. `npx expo export` to build the production server. 2. `npx expo serve` to host it, by default on `http://localhost:8081`. 3. Temporarily set the plugin `origin` to that URL and build in release mode with automatic deployment disabled, for example `EXPO_NO_DEPLOY=1 npx expo run:ios --configuration Release`. Remove the local origin before building for the store. ## Production judgment | Concern | What to decide | |---|---| | Old binaries | The API contract must stay compatible with every native version still installed | | Origin per environment | Staging and production builds need different origins | | Web vs native callers | Web calls are same-origin; native calls come from anywhere and need their own abuse protection | | Absolute vs relative URLs | An explicit base URL from config is easier to reason about than relying on the origin mapping | - Treat a missing origin as a release-checklist item; the failure only appears in release builds. - Log the server URL a build uses at startup in internal builds, so a wrong origin is visible immediately. This behaviour is documented for Expo SDK 57 with Expo Router 57.x; native deployment of server features is still marked alpha.
- Why does automatic server deployment during the native build help with old app versions?With `EXPO_UNSTABLE_DEPLOY_SERVER=1`, each EAS Build deploys a versioned server and writes that deployment's origin into the build. A binary in users' hands keeps calling the server it shipped with, instead of a single shared origin whose API may have moved on. The API still has to be kept stable for as long as those versions are supported.
- The release build now reaches the server, but the waitlist call returns 500. What do you check first?The server's environment. The deployed server does not load `.env` files, so a `WAITLIST_API_KEY` that existed locally may be undefined in production and the upstream call fails. Configure the variable on the host or in EAS Hosting, then check the server logs for the thrown error behind the 500.
saying these in an interview costs you the question
- Relative fetch URLs resolve against the app's bundle on a device
- The dev server's success proves the production server exists
- API routes are compiled into the native app binary
- Changing origin takes effect without rebuilding the app
- A deployed expo-server reads the project's .env file