Deploy your API routes
Put Expo Router API routes on EAS Hosting, give them server keys, smoke-test /api/health, and point every build profile at the deployed URL.
Hey - by the end of this page your API routes answer at https://your-app.expo.app, with their
server keys set on EAS, and your builds call that URL instead of your laptop.
While you develop, bun run start serves src/app/api/** from the dev server. A release build
has no dev server, so anything that talks to your routes - Stripe Checkout, Better Auth, account
deletion - needs them deployed first.
Before you start
- Tier: Pro. The
backend=api-routesmodule isn't in Free or Starter. - Time: about 30 minutes.
- Runs in: anything - the routes run on EAS Hosting, not on the device, so Expo Go works too.
- Accounts: an Expo account with a linked EAS project (Ship to TestFlight has the commands). EAS Hosting has a free tier.
- Previous tutorial: Add analytics and crash reporting.
Picked backend=api-routes at setup? Skip step 1. Step 1 needs modules/, so it works only
in a tree set up with --keep-modules; a finalized tree without it starts from a fresh clone -
see Can I change a module after setup?.
1. Add the module
bun run setup --backend api-routes --yes --keep-modulesIt patches the app config with web.output: "server", which is what makes API routes build (the
static web export is off while it's selected), and adds src/app/api/health+api.ts,
src/app/api/echo+api.ts, src/app/api/privacy/delete+api.ts and the helpers in
src/server/.
You should see: src/app/api/ and src/server/ in your tree.
2. Run the routes locally
bun run startIn another terminal:
curl -s http://localhost:8081/api/healthYou should see: {"ok":true,"version":"1.0.0","variant":"dev"} - the version is the one in
package.json.
3. Put the server keys on EAS
Deployed routes read their keys from an EAS environment, never from your .env. Server keys
(no EXPO_PUBLIC_ prefix) go in as sensitive - not secret, which EAS Hosting can't
deploy. EXPO_PUBLIC_* keys your routes read (the Supabase ones, for example) go in as
plaintext. Environment variables โ On EAS explains the
three visibilities.
bunx eas-cli env:set --name WEBHOOK_SECRET --value your-random-string --environment production --visibility sensitiveThe others depend on your stack:
| Your stack | Server keys |
|---|---|
payments=stripe | STRIPE_SECRET_KEY, STRIPE_PRICE_ID, STRIPE_WEBHOOK_SECRET (live values) |
auth=better-auth | BETTER_AUTH_SECRET, BETTER_AUTH_URL (the deployed origin), DATABASE_URL |
auth=clerk | CLERK_SECRET_KEY (or CLERK_JWT_KEY), CLERK_WEBHOOK_SIGNING_SECRET |
auth=supabase | none - EXPO_PUBLIC_SUPABASE_URL and _ANON_KEY as plaintext |
| Account deletion (optional) | POSTHOG_PERSONAL_API_KEY + POSTHOG_PROJECT_ID, AMPLITUDE_API_KEY + AMPLITUDE_SECRET_KEY, REVENUECAT_SECRET_KEY |
bunx eas-cli env:list --environment productionYou should see: every key your routes read, in the production environment.
4. Deploy
A server deploy runs in this order: pull the environment so the export sees the same values, export the web bundle (which carries the API routes), then deploy with the same environment:
bunx eas-cli env:pull --environment production
bunx expo export --platform web
bunx eas-cli deploy --environment productionenv:pull writes .env.local, which also wins over .env in local development - delete it
once the deploy is done. The first eas deploy asks you to pick a preview subdomain, say
trailmix. Re-run the export before every deploy.
You should see: a preview URL such as https://trailmix--abc123xyz.expo.app and a link to the
deployment on expo.dev.
5. Smoke-test it
curl -s https://trailmix--abc123xyz.expo.app/api/healthYou should see: {"ok":true,...}. The variant field reads dev on a deploy - it
reflects EXPO_PUBLIC_APP_VARIANT, which only builds set.
Happy with it? Promote it to the production URL:
bunx eas-cli deploy --prod --environment productionThat deploys dist/ again as the production deployment, at https://trailmix.expo.app.
6. Point the app at it
EXPO_PUBLIC_API_URL is compiled into each build and update, so set it per EAS environment: the
production URL for production and, if you want a staging backend, an alias for preview
(bunx eas-cli deploy --alias staging gives you https://trailmix--staging.expo.app):
bunx eas-cli env:set --name EXPO_PUBLIC_API_URL --value https://trailmix.expo.app --environment production --visibility plaintext
bunx eas-cli env:set --name EXPO_PUBLIC_API_URL --value https://trailmix--staging.expo.app --environment preview --visibility plaintextThen ship it: a new build (Ship to TestFlight) or, since it's only a JavaScript value, an update (Your first update):
bunx eas-cli update --channel production --environment production --message "Use hosted API"Keep http://<lan-ip>:8081 in your local .env for development.
You should see: the installed build reach the hosted routes with your laptop's dev server stopped - for example, the Stripe paywall shows its price, or Better Auth signs you in.
7. Re-wire what depends on it
Anything registered against your dev URL needs the deployed one now:
- Stripe (
/api/stripe/checkout,/entitlements,/webhook,/return): in live mode, add a webhook endpoint athttps://trailmix.expo.app/api/stripe/webhookand set itswhsec_โฆasSTRIPE_WEBHOOK_SECRET- see Add a paywall. - Better Auth (
/api/auth/*):BETTER_AUTH_URLmust be the deployed origin, and every OAuth redirect URI (https://trailmix.expo.app/api/auth/callback/google,โฆ/apple) re-registered against it. Swap the memory database for Postgres before real users arrive. - Account deletion (
/api/privacy/delete): Settings โ Delete account calls it before the auth provider deletes the user, to erase them in PostHog, Amplitude and RevenueCat when those server keys are set. It answers 503 until server auth is configured. - Clerk (
/api/webhooks/clerk): point theuser.deletedwebhook at the deployed URL.
After changing keys, run step 4 again - a deploy reads the environment at deploy time.
You should see: if you use Stripe, successful deliveries to the new endpoint on the Stripe dashboard's webhook page.
Check it
curl -s https://trailmix.expo.app/api/healthbun run doctorThe health route should answer {"ok":true,...}, and every backend row should be green.
If it doesn't work
- 404 on every
/api/*URL - the export ran without the module'sweb.output: "server", or you deployed an olddist/. Runbunx expo export --platform webagain, then deploy. - 500 from a route that works locally - a server key is missing from the environment you
deployed with, or it's stored as
secret. Fix it withenv:set --visibility sensitiveand deploy again. - The app still calls your laptop - the build or update was made before
EXPO_PUBLIC_API_URLwas set on EAS, or a leftover.env.localoverrode it locally. Publish an update with--environment production. - iOS refuses the request - the URL must be https; EAS Hosting always is, so check for a
leftover
http://value. - Local dev suddenly uses production values - delete the
.env.localthatenv:pullwrote.
More in Troubleshooting.
Congrats ๐
Your backend is live on EAS Hosting, with its keys on EAS and every build pointed at it - so Checkout, sign-in and account deletion work without your laptop. To take the module out again, see API routes โ Remove it. For when to outgrow API routes, read Do I need a backend?.
Next, get a build to testers: Ship to TestFlight.
Add analytics and crash reporting
Wire PostHog or Amplitude and Sentry into an Expo app behind a consent sheet, track your own events, and get readable stack traces.
Ship to TestFlight
Link the EAS project, move your keys to EAS, build the iOS app and get it to TestFlight testers - by hand first, then from a git tag.