ReadyNative

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

Pro

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
  1. 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.
  2. Run bun run start. The dev server serves src/app/api/** at http://localhost:8081/api/* - check it with curl -s http://localhost:8081/api/health, which returns {"ok":true,...}.
  3. Set EXPO_PUBLIC_API_URL in .env to http://<your-lan-ip>:8081 and restart with bun run start -- -c. A physical device can't reach localhost, and the example screen shows an EmptyState while the key is unset.
  4. Add any server-only key to .env under the # server (never EXPO_PUBLIC) heading. The shipped one is WEBHOOK_SECRET (any random string), read through serverEnv().WEBHOOK_SECRET.
  5. Deploy when you're ready: bunx eas-cli login && bunx eas-cli init (it prints the project id - paste it into readynative.config.ts → app.easProjectId), then npx expo export -p web and eas deploy - add --prod for the production alias. The guide is at https://docs.expo.dev/eas/hosting/get-started/.
  6. 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.

  1. Run bun run doctor - every row for this module should be green.

The keys, in one place:

KeyWhereGet it
EXPO_PUBLIC_API_URLclient (env.API_URL); shared with the data moduleshttp://<lan-ip>:8081 while expo start runs, else the hosted URL
WEBHOOK_SECRETserver only (serverEnv().WEBHOOK_SECRET), .env/EASany random string; also eas env:set --environment production --visibility sensitive
POSTHOG_PERSONAL_API_KEYserver only, optionalPostHog → Settings → Personal API keys, scope person:write; used by /api/privacy/delete
POSTHOG_PROJECT_IDserver only, optionalPostHog → Project settings → Project ID
POSTHOG_API_HOSTserver only, optionaldefault https://us.posthog.com; https://eu.posthog.com for EU projects
AMPLITUDE_API_KEY, AMPLITUDE_SECRET_KEYserver only, optionalAmplitude → Settings → Projects → your project → API key and Secret key
AMPLITUDE_REGIONserver only, optionaleu for EU-hosted projects; US otherwise
REVENUECAT_SECRET_KEYserver only, optionalRevenueCat → Project settings → API keys → secret key
CLERK_WEBHOOK_SIGNING_SECRETserver only, optionalClerk → 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/ with POSTHOG_PERSONAL_API_KEY, distinct_ids, delete_events: true and delete_recordings: true.
  • Amplitude: the User Privacy API v1, POST https://amplitude.com/api/2/deletions/users (https://analytics.eu.amplitude.com with AMPLITUDE_REGION=eu), Basic auth AMPLITUDE_API_KEY:AMPLITUDE_SECRET_KEY, user_ids and ignore_invalid_id: true.
  • RevenueCat: DELETE https://api.revenuecat.com/v1/subscribers/{app_user_id} with REVENUECAT_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" makes npx expo export -p web produce 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_URL at EAS Hosting before you ship.
  • EXPO_PUBLIC_API_URL is 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):

  1. Run bun run start, then curl -s localhost:8081/api/health → {"ok":true,"version":"1.0.0","variant":"dev"}.
  2. 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 }.
  3. Set EXPO_PUBLIC_API_URL to the LAN URL and open Examples → "API route" on a device: it shows ok · version · variant. Unset it and you get the EmptyState.
  4. After eas deploy, repeat step 1 against https://<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-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. 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.
  2. Replace, don't delete src/lib/account-data.ts: core code imports it, so swap in the no-op version from modules/backend/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. 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_SECRET from .env, .env.example and your EAS environment, and EXPO_PUBLIC_API_URL from src/lib/env.ts.
  4. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/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-routes

Module id: backend/api-routes (the default for this category).

Environment keys

KeyRequiredServer-onlyExampleDocs
EXPO_PUBLIC_API_URLnonohttp://localhost:8081dashboard
WEBHOOK_SECRETnoyeschange-medashboard
POSTHOG_PERSONAL_API_KEYnoyesphx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxdashboard
POSTHOG_PROJECT_IDnoyes12345dashboard
POSTHOG_API_HOSTnoyeshttps://us.posthog.comdashboard
AMPLITUDE_API_KEYnoyes-dashboard
AMPLITUDE_SECRET_KEYnoyes-dashboard
AMPLITUDE_REGIONnoyesusdashboard
REVENUECAT_SECRET_KEYnoyessk_xxxxxxxxxxxxxxxxxxxxxxxxdashboard
CLERK_WEBHOOK_SIGNING_SECRETnoyeswhsec_xxxxxxxxxxxxxxxxxxxxxxxxdashboard

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

  1. API routes: expo start serves src/app/api/** on the dev server; set EXPO_PUBLIC_API_URL=http://<lan-ip>:8081 in .env for a device
  2. Deploy: npx eas-cli login, eas init, then npx expo export -p web && eas deploy (EAS Hosting) and point EXPO_PUBLIC_API_URL at the hosted URL
  3. Server secrets (WEBHOOK_SECRET, …) live in .env locally and in eas env:set --environment production --visibility sensitive for EAS Hosting - never EXPO_PUBLIC_
  4. 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.deleted webhook at /api/webhooks/clerk and set CLERK_WEBHOOK_SIGNING_SECRET.

Files

14 files copied to the project root
  • 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/lib/account-data.ts
  • 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

On this page

Get ReadyNative