With Expo Router API routes, how do you add a waitlist signup endpoint, and what keeps its API key out of the app bundle?
answer
- name ends in +api.ts
- export POST(request: Request)
- missing method: 405; thrown error: 500
- web.output must be server
- secret stripping via expo/metro-config
basics
~20 sCreate src/app/api/waitlist+api.ts exporting a POST(request) function that returns a Response, with web.output set to server. Code only API routes import runs on the server and is stripped from the client bundle, so the key stays server-side.
solid answer
~30 sI add `src/app/api/waitlist+api.ts` and export `POST(request: Request)`: it reads the body with `request.json()`, validates the email, calls the waitlist service and returns `Response.json(...)`. A route can export `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`; other methods get 405, and a thrown error becomes 500. It needs `web.output: 'server'`, and in production it runs on EAS Hosting or an `expo-server` adapter. The key stays secret because API routes can read any environment variable, not just `EXPO_PUBLIC_` ones, and code imported only by `+api.ts` files is stripped from the client bundle by `expo/metro-config`. If a screen imports that module, the secret ships.
code
typescript · 22 lines// src/app/api/waitlist+api.ts
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const email = typeof body?.email === 'string' ? body.email.trim() : '';
if (!email.includes('@')) {
return Response.json({ error: 'invalid email' }, { status: 400 });
}
const upstream = await fetch('https://waitlist.example.com/v1/entries', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WAITLIST_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!upstream.ok) {
return Response.json({ error: 'try again later' }, { status: 502 });
}
return Response.json({ ok: true }, { status: 201 });
}go deeper
Recall the file naming, +api.ts, and that the file exports functions named after HTTP methods which return a Response.
Explain the handler contract: Request in, Response out, 405 and 500 behaviour, dynamic segment params, and the server output setting that enables it.
Show how secrets stay server-side, why a shared import leaks them, and that the endpoint is public and needs validation and rate limiting.
Decide when a co-located API route is enough and when the waitlist belongs in a dedicated backend, weighing deployment coupling and portability.
## API routes in Expo Router An **API route** is a file under `src/app` whose name ends in `+api.ts`. Instead of a screen, it exports functions named after HTTP methods, and Expo runs them on a **server** when a request matches the file's path. It lets a project keep a small backend next to its screens: exchanging an auth code, calling a service with a secret key, or accepting a waitlist signup. For a habit-tracker app's marketing site, `src/app/api/waitlist+api.ts` answers requests to `/api/waitlist`: ```ts export async function POST(request: Request) { const { email } = await request.json(); if (typeof email !== 'string' || !email.includes('@')) { return Response.json({ error: 'invalid email' }, { status: 400 }); } await addToWaitlist(email, process.env.WAITLIST_API_KEY); return Response.json({ ok: true }, { status: 201 }); } ``` ## The handler contract - **Method exports.** A route may export `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` and `OPTIONS`. A request with a method the file does not export gets **405 Method Not Allowed** automatically. - **Standard web objects.** The handler receives the global `Request` and returns a `Response`; `request.json()` parses a JSON body, and query parameters come from `new URL(request.url).searchParams`. - **Dynamic segments.** A file such as `src/app/invites/[code]+api.ts` receives the matched segment in its second argument, for example `{ code }`. - **Errors.** Returning a `Response` with any status is the normal path. An exception thrown inside the handler becomes **500 Internal Server Error**. From SDK 54 the `expo-server` package also provides `StatusError`, which you can throw to return a specific status early. - **No platform suffixes.** `waitlist+api.web.ts` is not a valid API route; server files have one implementation. ## Turning them on API routes require `web.output` set to `server` in the app config. In development the Expo dev server runs them at `http://localhost:8081/...` (and warns if the output is not `server`), so `curl` or the app itself can call them. For production, `npx expo export --platform web` writes the server bundle to `dist/server`, and something must execute it: 1. **EAS Hosting**, deployed with `eas deploy`. 2. Another host, through an adapter from the `expo-server` package; each adapter serves the static files from `dist/client` and delegates other requests to the routes in `dist/server`. ## Keeping secrets on the server The reason to use an API route rather than calling the waitlist service from the screen is the **secret**: | Where the value lives | Reaches the client bundle? | |---|---| | `process.env.X` read inside a `+api.ts` file (or a module only it imports) | No | | `process.env.EXPO_PUBLIC_X` read in a screen | Yes, inlined at build time | | A module with a secret imported by any screen file | Yes | API routes can read **every** environment variable, not only `EXPO_PUBLIC_` ones, and code imported only by `+api.ts` files is kept out of the client bundle. That stripping is done by `expo/metro-config`, so the project's `metro.config.js` must build on it. In a deployed server, `expo-server` does not load `.env` files; the host must supply the variables. ## Calling the route from web and native On the web, `fetch('/api/waitlist', { method: 'POST', ... })` resolves against the site's own origin. On iOS and Android, relative URLs resolve against the dev server in development, and against the `origin` set in the `expo-router` config plugin in a release build, so a native release build needs the server deployed and that origin configured. ## Design points worth raising - **Validate input.** The handler is a public HTTP endpoint; anyone can post to it, not just your app. - **Rate-limit and de-duplicate.** A waitlist endpoint is an easy spam target. - **Known limits.** API routes are bundled into a single CommonJS file with their dependencies, so packages that ship platform-specific native binaries cannot be used. - **Keep business logic portable.** Keep the handler thin, so the logic can move to a dedicated backend later. This describes Expo SDK 57 with Expo Router 57.x; `expo-server` and its helpers are available from SDK 54.
- How does an Expo Router API route receive the value of a dynamic segment such as [code]?A file named `src/app/invites/[code]+api.ts` matches `/invites/abc123`, and Expo passes the matched segments as the handler's second argument, so `GET(request, { code })` receives `code` as a string. Query-string values are not in that object; read them from `new URL(request.url).searchParams`.
- The waitlist key appears in the web bundle even though only the API route uses it. What happened?Some client file imports the module that reads the key, for example a shared `waitlist.ts` helper used by both the route and a screen. Anything a screen imports is bundled for the client. Keep secret-reading code in modules imported only by `+api.ts` files, make sure `metro.config.js` extends `expo/metro-config`, and never give a secret the `EXPO_PUBLIC_` prefix.
- Why might a dependency that works in a Node.js script fail inside an Expo Router API route?API routes are bundled by Metro into a single CommonJS file with their dependencies. Packages that ship platform-specific native binaries, or rely on loading files dynamically at runtime, cannot be bundled that way, so they fail. Prefer pure JavaScript libraries or call a separate service over HTTP.
saying these in an interview costs you the question
- Unsupported methods fall through to the GET handler
- API routes can only read EXPO_PUBLIC_ variables
- A secret is safe anywhere under src/app
- waitlist+api.web.ts gives a web-only version
- API routes run inside the native app on the device
- A thrown exception returns a 400 to the caller