Expo Router API Routes in 2026: Server Endpoints on EAS Hosting
Build server endpoints inside your Expo app with +api.ts handlers, then ship them on EAS Hosting. Covers server output, dynamic segments, auth, middleware, env vars, testing, and honest limits.
Expo Router API Routes are server-side endpoints you write inside your Expo app under files named +api.ts, and in 2026 they run either at build time in your development server or in production on EAS Hosting and any Node-compatible platform. If you have shipped Next.js Route Handlers, the mental model is nearly identical. The file exports async functions named after HTTP verbs, they receive a standard Request, and they return a standard Response. What changes is where the code runs (the same repo as your mobile app) and how the mobile client discovers the URL (via process.env.EXPO_PUBLIC_API_URL or the app origin).
API Routes live in app/**/+api.ts and export async functions named GET, POST, PUT, PATCH, DELETE, or OPTIONS.
You must set "web": { "output": "server" } in app.json. Without server output, routes are silently dropped from the build.
Handlers use the Web Fetch API, the same Request/Response primitives that Next.js Route Handlers and Cloudflare Workers use.
Production deployment on EAS Hosting is eas deploy; the CLI uploads the server bundle and gives you a preview URL per commit.
Environment variables at runtime read from process.env; only variables prefixed EXPO_PUBLIC_ ship in the client bundle.
API Routes can't hold open WebSocket connections and time out at 30 seconds on EAS Hosting, so use a dedicated service for long-running work.
What are Expo Router API Routes?
Expo Router API Routes are file-based HTTP endpoints that live alongside your screens in the app/ directory. Any file whose name ends in +api.ts (or +api.js) becomes a server handler at the URL that mirrors its file path. So app/hello+api.ts serves /hello, and app/users/[id]+api.ts serves /users/42. Coming from React Web, this is the same mental furniture as Next.js Route Handlers or SvelteKit endpoints: the router treats HTTP verbs as named exports, the runtime is a subset of Node with the Fetch API on top, and the handler contract is (req: Request) => Response | Promise<Response>.
The reason this matters for a mobile team is co-location. Before API Routes shipped, you had two choices: put every "small" endpoint (feature flags, image proxies, webhook receivers, RevenueCat validators) into a separate Node service, or lean on Firebase Functions and lose type-sharing with the client. API Routes let a single repository hold the mobile app, the shared type definitions, and the server handlers that consume them. In my experience shipping cross-platform apps, that co-location cuts the cycle time on a new endpoint from "spin up a repo and a deploy pipeline" to "add a file and commit."
Since Expo SDK 52 the feature has been stable, and the SDK 55 release cycle added first-class API Routes deployment on EAS Hosting, so you no longer need Vercel or a self-hosted Node process to run them in production. That said, API Routes are not a general-purpose backend. They're best treated as the "edge helpers" of your app, and the closing section is honest about where they run out of steam.
Enable server output in your Expo app
Honestly, the single most common footgun with API Routes is forgetting to switch the web output mode. By default, an Expo Router project renders to output: "static", and static builds have nowhere to run server code, so your +api.ts files compile fine, but at request time you get a 404 with no obvious clue. I hit this one exactly once and lost half an afternoon before checking app.json. Set the output to server:
You also need to be on a Metro-based web bundler; the older Webpack config never supported server output. If you have an existing project on output: "static", note that switching to server changes how prerendering works for your public web pages too. Every route becomes SSR by default; if you have marketing pages that must be static, add export const unstable_settings = { static: true } to those specific screen files (behavior aligned with Next.js's export const dynamic = "force-static").
Restart the dev server after this change, not just the Metro cache. npx expo start --clear is the safe reset. If you use a custom Expo config plugin, make sure it doesn't override the web block. I've seen plugins that stamp output: "static" onto every project silently break API Routes.
Write your first +api.ts route
So, let's write one. Create app/hello+api.ts:
// app/hello+api.ts
export async function GET(request: Request) {
const url = new URL(request.url)
const name = url.searchParams.get("name") ?? "world"
return Response.json(
{ message: `hello, ${name}` },
{ status: 200, headers: { "cache-control": "public, max-age=60" } }
)
}
export async function POST(request: Request) {
// The Request body is a standard ReadableStream. Parse with .json()/.text()/.formData().
const body = await request.json()
if (typeof body?.email !== "string") {
return Response.json({ error: "email required" }, { status: 400 })
}
// ...do work, e.g. write to a DB
return Response.json({ ok: true }, { status: 201 })
}
Start the app with npx expo start and hit http://localhost:8081/hello?name=anita in a browser; you should see the JSON body. Every handler receives the DOM Request class (the same one used by fetch() in the browser and by the Fetch API in modern Node) and every handler must return a Response. There's a helper, Response.json(data, init), for the common case of serializing JSON with a correct content-type.
If you're converting a Next.js codebase, this pattern will feel familiar; the differences are small but real. There is no NextRequest/NextResponse, so you use the platform types directly. There is no automatic file-based route grouping via parentheses (yet); parentheses do work for organizing screens but don't currently apply to +api siblings the way they do to layouts.
Dynamic segments and route parameters
Dynamic segments use the same square-bracket syntax as screens. Create app/users/[id]+api.ts:
// app/users/[id]+api.ts
export async function GET(request: Request, { id }: Record<string, string>) {
const user = await getUserById(id)
if (!user) {
return new Response(null, { status: 404 })
}
return Response.json(user)
}
Note the second argument: unlike screens (where you read params with the useLocalSearchParams() hook), API Route handlers receive the resolved dynamic segments as a plain object. Catch-all segments work too. app/proxy/[...path]+api.ts passes { path: string[] } as the second arg, useful for building image proxies or feature-flag prefixes that forward the tail of the URL to an upstream service.
One quirk worth flagging: an OPTIONS preflight is not auto-generated. If a browser (or a strict fetch client) requires CORS preflight, you have to export OPTIONS yourself and return the appropriate access-control-allow-* headers. For mobile-only clients this rarely matters, but as soon as you point your React Native Web build at these endpoints from a different origin, add the preflight or you'll chase 405 errors.
Call an API route from the mobile client
API Routes aren't automatically wired to the mobile client. React Native's fetch() needs an absolute URL, so you have to know where your server lives. The idiomatic pattern is a public env var:
// .env
EXPO_PUBLIC_API_URL=https://my-app.expo.app
// hooks/useUser.ts
import { useQuery } from "@tanstack/react-query"
const BASE = process.env.EXPO_PUBLIC_API_URL ?? ""
export function useUser(id: string) {
return useQuery({
queryKey: ["user", id],
queryFn: async () => {
const res = await fetch(`${BASE}/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
})
}
During development, EXPO_PUBLIC_API_URL should point at your Metro dev server's tunnel URL (usually http://192.168.1.x:8081 or the LAN URL Expo prints on start). In production, it's the EAS Hosting deployment URL. If you haven't adopted TanStack Query yet, I'd nudge you toward Zustand plus TanStack Query. API Routes shine when the client cache and the server contract are both typed once and reused. The rewards compound.
Authentication, cookies, and headers
Because the request/response model is Web-standard, authentication looks exactly like it does on any other Fetch-based backend. Read request.headers.get("authorization"), verify a bearer token, and gate the handler:
Cookies work through the standard Set-Cookie header. React Native's fetch() doesn't persist cookies by default across app launches, so for mobile sessions you usually prefer a bearer token stored in expo-secure-store and sent as an Authorization header. If your same code powers the React Native Web build in a browser, layer credentials: "include" onto the fetch call and use httpOnly cookies so the browser handles session persistence for you.
For webhook receivers (Stripe, RevenueCat, GitHub) you also need the raw request body to verify HMAC signatures (see the Stripe webhook signature docs for the canonical example). Use await request.text() before any JSON parsing; once you call .json() the body stream is consumed.
Middleware and request pipelines
As of SDK 55, Expo Router supports a top-level middleware.ts file that runs before every request and can short-circuit, rewrite, or redirect. It's close in spirit to Next.js middleware but simpler: one file, one exported function, standard Fetch types:
// middleware.ts
export default async function middleware(request: Request) {
const url = new URL(request.url)
// Block a hot-linked path
if (url.pathname.startsWith("/private/") && !request.headers.get("cookie")) {
return Response.redirect(new URL("/login", url), 307)
}
// Attach a request id and continue to the route handler
return { headers: { "x-request-id": crypto.randomUUID() } }
}
Returning a Response ends the request; returning an object with headers lets the handler continue with extra headers merged in. There's no per-directory middleware nesting yet, so if you need scoped middleware (e.g. auth only on /api/admin/*), you branch on url.pathname at the top of the single file. That's the current constraint, and it's worth planning for if you expect a deep API tree.
How do you deploy Expo Router API routes?
The 2026 answer for most teams is EAS Hosting. It's Expo's first-party runtime for server output, it understands the same app.json you already use for builds, and it deploys in one command. Install the CLI (npm i -g eas-cli) if you haven't, sign in with eas login, then from the project root run:
eas deploy
On first run EAS asks you to link the project (eas init if it's a greenfield app), then uploads a compiled server bundle to a preview subdomain like https://my-app--pr42.expo.app. Use eas deploy --prod to promote a deployment to the production alias. Because EAS Hosting is designed for the same OTA-update model as EAS Workflows, you can wire deploys into the same CI without inventing a new pipeline.
If you don't want EAS Hosting, the server bundle is a plain Node process. npx expo export --platform web produces a dist/ folder with a server/ subdirectory you can boot with any Node 20+ runtime. It runs on Vercel (as a Node handler, not a Vercel Function), on Cloudflare Workers with a small adapter, on Fly.io, or on your own container. The Fetch-standard handler surface is the reason it ports so cleanly.
Environment variables and secrets
Runtime env vars behave differently in server code than in client code, and this trips people up, so it's worth being explicit. On the server (inside a +api.ts handler), process.env.ANY_VARIABLE is available at request time from the deployment's environment. On the client (inside a screen or a hook that runs on the device), only variables prefixed with EXPO_PUBLIC_ are inlined by Metro at build time; everything else is stripped.
// app/webhook+api.ts, SERVER, can read secrets
export async function POST(request: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET // ok
// ...
}
// app/(tabs)/index.tsx, CLIENT, cannot read secrets
export default function Home() {
const secret = process.env.STRIPE_WEBHOOK_SECRET // undefined
const publicUrl = process.env.EXPO_PUBLIC_API_URL // ok
return null
}
Set server env vars with eas env:create --scope project --environment production --name STRIPE_WEBHOOK_SECRET. During local development, a .env file at the project root is loaded automatically by Expo CLI (don't commit it, and add it to .gitignore on day one). The distinction between "secret" and "public" mirrors Next.js's NEXT_PUBLIC_ convention exactly, which is a comfortable transition for anyone moving from web.
Testing +api.ts handlers
Because handlers are pure functions of Request → Response, they're extremely testable, with no server to boot, no supertest, and no mocks. Import the handler directly into a Jest test and call it with a hand-built Request:
// app/hello+api.test.ts
import { GET, POST } from "./hello+api"
describe("hello+api", () => {
it("returns a greeting", async () => {
const res = await GET(new Request("http://test/hello?name=anita"))
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ message: "hello, anita" })
})
it("rejects a missing email", async () => {
const res = await POST(
new Request("http://test/hello", {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
})
)
expect(res.status).toBe(400)
})
})
Two setup notes. First, your Jest environment must expose the Fetch globals. Node 20+ has them built in, but if you're on the default jest-expo preset you may need to add testEnvironment: "node" for these specific tests so the DOM shim doesn't override the platform Request. Second, if your handler talks to a database, wrap the DB client behind an import so you can mock it with Jest's module mocking, or use an in-memory SQLite for the test suite. The rest of the project's approach to test infrastructure lines up with our existing React Native testing setup.
Limits, edge cases, and when to reach for a real backend
API Routes are a superb fit for the "10% of your backend that lives one door away from the mobile client." They're the wrong tool for the other 90%. As of the 2026 EAS Hosting runtime, the hard limits are: 30-second request timeout, no persistent WebSocket connections, no background jobs after a response is returned, 4.5 MB request body limit by default, and no long-lived server state (each invocation is stateless and cold-starts if idle). If you need any of those, you need a proper server.
What API Routes are ideal for: signed URL minting for uploads, webhook receivers (Stripe, RevenueCat, GitHub), auth callback endpoints (OAuth PKCE), feature-flag proxies, small image transformations, App Store server-to-server notification receivers, and BFF-style aggregation of two or three upstream APIs into one payload your mobile screen can consume. What they're wrong for: multiplayer game servers, LLM streaming that exceeds 30 seconds, chat with persistent connections, video transcoding, and anything that needs a queue.
Frequently Asked Questions
Can Expo Router API routes replace a full backend?
No. They're excellent for small, stateless endpoints (webhooks, auth callbacks, BFF aggregation) but don't support WebSockets, background jobs, or requests longer than 30 seconds on EAS Hosting. Use them for edge helpers alongside a dedicated backend for heavy work.
Are Expo Router API routes free?
Local development is free. In production, EAS Hosting has a free tier for hobby projects and paid usage-based tiers for production traffic; pricing is per request and per bandwidth. Self-hosting on your own Node server has no per-request cost.
Do API routes work in native Expo Go builds?
Not directly. API Routes only run when the app has server output; on a device, the mobile client fetches them over HTTP from the deployed URL. During development Expo Go can reach the routes because Metro serves them on your LAN URL, but they're never bundled into the native binary.
How do I fix a 404 on my +api.ts route?
Ninety percent of the time it's output: "static" in app.json. Flip it to "server", run npx expo start --clear, and hit the route again. The remaining causes: file misnamed (must end in +api.ts), missing expo-router plugin, or the wrong HTTP verb (a GET handler will 405 a POST).
What is the difference between Expo API Routes and Next.js Route Handlers?
The programming model is essentially the same (file-based, Fetch-standard, verb-named exports), but Expo API Routes live in the same repository as your React Native app and deploy to EAS Hosting. Next.js Route Handlers are web-only and deploy to Vercel or a Node host. If you already know Next.js, you already know 95% of Expo API Routes.
Capture heap snapshots from a Hermes-powered React Native app in Expo, read retainer paths in Chrome DevTools, and catch the three most common leaks with numbers, traces, and CI regression tests.
Ship native Apple Maps and Google Maps in a React Native app with expo-maps: install the config plugin, render your first map, drop markers, handle camera events, and decide when to stay on react-native-maps.