API routes
Set up API routes for backend in an Expo app with ReadyNative: Expo Router +api.ts routes · src/server helpers · EAS Hosting · Expo Go OK
Expo Go: yes
Runs in Expo Go; no dev build needed for this module.
This turns on Expo Router's server routes so your app has a backend in the same tree: src/app/api/health+api.ts answers GET /api/health with { ok, version, variant }, src/app/api/echo+api.ts answers POST /api/echo { message } with zod validation and a 400 on bad input, and src/server/ holds the pieces you'll reuse - json.ts (json(), error(), readJson(request, schema), handle() which maps BadRequest to 4xx and anything else to 500 without leaking a stack), env.ts (serverEnv(), zod over process.env for keys that must never be EXPO_PUBLIC_), and webhooks/README.md with the handler pattern. If you set up with --with-examples there's an example at /examples/api. Pick this when a module needs a server - auth/better-auth requires it and payments/stripe wants it - or when you'd rather not stand up a separate backend yet.
Setup
bun run setup --backend api-routes- The module patches the app config with
web.output: "server", which is what makes API routes build. Your native export is unaffected, but the static web export is gone while this module is selected. - Run
bun run start. The dev server servessrc/app/api/**athttp://localhost:8081/api/*- check it withcurl -s http://localhost:8081/api/health, which returns{"ok":true,...}. - Set
EXPO_PUBLIC_API_URLin.envtohttp://<your-lan-ip>:8081and restart withbun run start -- -c. A physical device can't reachlocalhost, and the example screen shows an EmptyState while the key is unset. - Add any server-only key to
.envunder the# server (never EXPO_PUBLIC)heading. The shipped one isWEBHOOK_SECRET(any random string), read throughserverEnv().WEBHOOK_SECRET. - Deploy when you're ready:
bunx eas-cli login && bunx eas-cli init(it prints the project id - paste it intoreadynative.config.ts→app.easProjectId), thennpx expo export -p webandeas deploy- add--prodfor the production alias. The guide is at https://docs.expo.dev/eas/hosting/get-started/. - Write custom webhooks against the pattern in
src/server/webhooks/README.md: verify the secret, parse with zod, and return 2xx only once you've actually handled it. The auth (/api/auth/[...all]) and payments handlers ship with their own modules.
Going to production?
Set the server keys in EAS rather than .env: eas env:set --environment production --name WEBHOOK_SECRET --value … --visibility sensitive, and deploy with eas deploy --environment production so the routes see them (EAS Hosting can't deploy secret variables). Point EXPO_PUBLIC_API_URL at https://<project>.expo.app per build profile in eas.json env, and optionally set expo.extra.router.origin so relative fetch("/api/…") calls resolve (https://docs.expo.dev/router/reference/api-routes/#deployment). A native release build has no API routes without a deployed server.
- Run
bun run doctor- every row for this module should be green.
The keys, in one place:
| Key | Where | Get it |
|---|---|---|
EXPO_PUBLIC_API_URL | client (env.API_URL); shared with the data modules | http://<lan-ip>:8081 while expo start runs, else the hosted URL |
WEBHOOK_SECRET | server only (serverEnv().WEBHOOK_SECRET), .env/EAS | any random string; also eas env:set --environment production --visibility sensitive |
POSTHOG_PERSONAL_API_KEY | server only, optional | PostHog → Settings → Personal API keys, scope person:write; used by /api/privacy/delete |
POSTHOG_PROJECT_ID | server only, optional | PostHog → Project settings → Project ID |
POSTHOG_API_HOST | server only, optional | default https://us.posthog.com; https://eu.posthog.com for EU projects |
AMPLITUDE_API_KEY, AMPLITUDE_SECRET_KEY | server only, optional | Amplitude → Settings → Projects → your project → API key and Secret key |
AMPLITUDE_REGION | server only, optional | eu for EU-hosted projects; US otherwise |
REVENUECAT_SECRET_KEY | server only, optional | RevenueCat → Project settings → API keys → secret key |
CLERK_WEBHOOK_SIGNING_SECRET | server only, optional | Clerk → Webhooks → your /api/webhooks/clerk endpoint → Signing secret (whsec_…) |
Server keys are listed in .env.example under # server (never EXPO_PUBLIC); bun run doctor checks the ones a module marks required. Expo Go works, since the routes run on the dev server or EAS Hosting, not on the device.
Usage
Write a route as a +api.ts file, using the shared helpers:
// src/app/api/greet+api.ts
import { z } from "zod";
import { handle, json, readJson } from "@/server/json";
const Body = z.object({ name: z.string().min(1) });
export const POST = handle(async (request) => {
const { name } = await readJson(request, Body);
return json({ greeting: `Hi ${name}` });
});Read a server-only key - never process.env directly, and never from client code:
import { serverEnv } from "@/server/env";
const secret = serverEnv().WEBHOOK_SECRET;Call it from the app against the configured origin:
import { env } from "@/lib/env";
const res = await fetch(`${env.API_URL}/api/health`);Account deletion
POST /api/privacy/delete authenticates the caller with serverAuth.getRequestUser (401 signed out, 503 when server auth isn't configured) and erases the verified user id in every service whose server keys are set:
- PostHog:
POST {POSTHOG_API_HOST}/api/projects/{POSTHOG_PROJECT_ID}/persons/bulk_delete/withPOSTHOG_PERSONAL_API_KEY,distinct_ids,delete_events: trueanddelete_recordings: true. - Amplitude: the User Privacy API v1,
POST https://amplitude.com/api/2/deletions/users(https://analytics.eu.amplitude.comwithAMPLITUDE_REGION=eu), Basic authAMPLITUDE_API_KEY:AMPLITUDE_SECRET_KEY,user_idsandignore_invalid_id: true. - RevenueCat:
DELETE https://api.revenuecat.com/v1/subscribers/{app_user_id}withREVENUECAT_SECRET_KEY; a 404 counts as deleted.
It answers 200 { results } when every service succeeded and 502 when one failed - the app then keeps the account so the user can retry. Settings calls deleteServerData() from @/lib/account-data (a POST to ${EXPO_PUBLIC_API_URL}/api/privacy/delete with auth.getAuthHeaders()) right before auth.deleteAccount(); with backend=none it's a no-op. PostHog and Amplitude can only find the user if the app called analytics.identify(session.user.id); RevenueCat's app user id is already the auth user id.
With Clerk, POST /api/webhooks/clerk handles user.deleted the same way. It verifies the Svix / Standard Webhooks signature with WebCrypto (no SDK, src/server/standard-webhooks.ts) against CLERK_WEBHOOK_SIGNING_SECRET with a 5-minute timestamp tolerance, and answers 404 while the secret is unset.
Gotchas
web.output: "server"makesnpx expo export -p webproduce a server bundle for EAS Hosting or Node, so the static web export is gone while this module is selected.- API routes aren't available in a native release build without a deployed server - point
EXPO_PUBLIC_API_URLat EAS Hosting before you ship. EXPO_PUBLIC_API_URLis shared with the data and auth modules; when several are selected, the first one's example value wins in.env.example.
Check it works (the Examples steps need a tree set up with --with-examples):
- Run
bun run start, thencurl -s localhost:8081/api/health→{"ok":true,"version":"1.0.0","variant":"dev"}. curl -s -X POST localhost:8081/api/echo -H 'content-type: application/json' -d '{"message":"hi"}'echoes it back; sending{}gives a 400 with{ error }.- Set
EXPO_PUBLIC_API_URLto the LAN URL and open Examples → "API route" on a device: it showsok · version · variant. Unset it and you get the EmptyState. - After
eas deploy, repeat step 1 againsthttps://<project>.expo.app.
Remove it
Remove auth/better-auth and payments/stripe first if you use it - it requires this module.
While modules/ exists (a tree set up with --keep-modules), setup does all of it:
bun run setup --backend none --yes --keep-modulesIn a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:
- Delete the files that are still there (the demo screens are gone already unless you set up with
--with-examples):src/app/api/echo+api.ts,src/app/api/health+api.ts,src/app/api/privacy/delete+api.ts,src/app/api/webhooks/clerk+api.ts,src/app/examples/api.tsx,src/screens/examples/api-example-screen.tsx,src/server/__tests__/privacy.test.ts,src/server/__tests__/routes.test.ts,src/server/env.ts,src/server/json.ts,src/server/privacy.ts,src/server/standard-webhooks.ts,src/server/webhooks/README.md. - Replace, don't delete
src/lib/account-data.ts: core code imports it, so swap in the no-op version frommodules/backend/none/files/of a fresh clone of your tier repo - same exports, nothing behind them. - Remove the env keys
EXPO_PUBLIC_API_URL,WEBHOOK_SECRET,POSTHOG_PERSONAL_API_KEY,POSTHOG_PROJECT_ID,POSTHOG_API_HOST,AMPLITUDE_API_KEY,AMPLITUDE_SECRET_KEY,AMPLITUDE_REGION,REVENUECAT_SECRET_KEY,CLERK_WEBHOOK_SIGNING_SECRETfrom.env,.env.exampleand your EAS environment, andEXPO_PUBLIC_API_URLfromsrc/lib/env.ts. - Check it:
bun run typecheckandbun run lintpoint at anything that still imports the removed files;bun run gen:graphrefreshesdocs/ARCHITECTURE.md.
Reference
Everything below is generated from modules/backend/api-routes/module.json - the same file bun run setup reads, so it is what actually lands in your repo.
Install
bun run setup --backend api-routesModule id: backend/api-routes (the default for this category).
Environment keys
| Key | Required | Server-only | Example | Docs |
|---|---|---|---|---|
EXPO_PUBLIC_API_URL | no | no | http://localhost:8081 | dashboard |
WEBHOOK_SECRET | no | yes | change-me | dashboard |
POSTHOG_PERSONAL_API_KEY | no | yes | phx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
POSTHOG_PROJECT_ID | no | yes | 12345 | dashboard |
POSTHOG_API_HOST | no | yes | https://us.posthog.com | dashboard |
AMPLITUDE_API_KEY | no | yes | - | dashboard |
AMPLITUDE_SECRET_KEY | no | yes | - | dashboard |
AMPLITUDE_REGION | no | yes | us | dashboard |
REVENUECAT_SECRET_KEY | no | yes | sk_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
CLERK_WEBHOOK_SIGNING_SECRET | no | yes | whsec_xxxxxxxxxxxxxxxxxxxxxxxx | dashboard |
Keys go in .env (see .env.example). Required keys are checked by bun run doctor; Server-only keys have no EXPO_PUBLIC_ prefix, are read only by API routes and never reach the bundle.
Doctor checks
- env:
EXPO_PUBLIC_API_URL
After setup
- API routes:
expo startserves src/app/api/** on the dev server; set EXPO_PUBLIC_API_URL=http://<lan-ip>:8081 in .env for a device - Deploy:
npx eas-cli login,eas init, thennpx expo export -p web && eas deploy(EAS Hosting) and point EXPO_PUBLIC_API_URL at the hosted URL - Server secrets (WEBHOOK_SECRET, …) live in .env locally and in
eas env:set --environment production --visibility sensitivefor EAS Hosting - never EXPO_PUBLIC_ - Account deletion: Settings → Delete account first calls POST /api/privacy/delete, which erases the user in PostHog / Amplitude / RevenueCat when their server keys are set (POSTHOG_PERSONAL_API_KEY + POSTHOG_PROJECT_ID, AMPLITUDE_API_KEY + AMPLITUDE_SECRET_KEY, REVENUECAT_SECRET_KEY). With Clerk, point a
user.deletedwebhook at /api/webhooks/clerk and set CLERK_WEBHOOK_SIGNING_SECRET.
Files
14 files copied to the project root
src/app/api/echo+api.tssrc/app/api/health+api.tssrc/app/api/privacy/delete+api.tssrc/app/api/webhooks/clerk+api.tssrc/app/examples/api.tsxsrc/lib/account-data.tssrc/screens/examples/api-example-screen.tsxsrc/server/__tests__/privacy.test.tssrc/server/__tests__/routes.test.tssrc/server/env.tssrc/server/json.tssrc/server/privacy.tssrc/server/standard-webhooks.tssrc/server/webhooks/README.md