API Routes
Edit page
Learn how to create server endpoints with Expo Router.
Expo Router enables you to write secure server code for all platforms, right in your app directory.
export function GET(request: Request) { return Response.json({ hello: 'world' }); }
Server features require a custom server, which can be deployed to EAS or most other hosting providers.

What are API Routes
API Routes are functions that are executed on a server when a route is matched. They can be used to handle sensitive data, such as API keys securely, or implement custom server logic, such as exchanging auth codes for access tokens. API Routes should be executed in a WinterCG-compliant environment.
In Expo, API Routes are defined by creating files in the app directory with the +api.ts extension. For example, the following API route is executed when the route /hello is matched.
appindex.tsxhello+api.tsAPI RouteCreate an API route
1
Ensure your project is using server output, this will configure the export and production builds to generate a server bundle as well as the client bundle.
{ "web": { "output": "server" } }
2
An API route is created in the app directory. For example, add the following route handler. It is executed when the route /hello is matched.
export function GET(request: Request) { return Response.json({ hello: 'world' }); }
You can export any of the following functions GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS from a server route. The function executes when the corresponding HTTP method is matched. Unsupported methods will automatically return 405: Method not allowed.
3
Start the development server with Expo CLI:
- npx expo4
You can make a network request to the route to access the data. Run the following command to test the route:
- curl http://localhost:8081/helloYou can also make a request from the client code:
import { Button } from 'react-native'; async function fetchHello() { const response = await fetch('/hello'); const data = await response.json(); alert('Hello ' + data.hello); } export default function App() { return <Button onPress={() => fetchHello()} title="Fetch hello" />; }
Relative fetch requests automatically fetch relative to the dev server origin in development, and can be configured in production using the origin field in the app.json:
{ "plugins": [ [ "expo-router", { "origin": "https://evanbacon.dev/" } ] ] }
This URL can be automatically configured during EAS Builds by setting the EXPO_UNSTABLE_DEPLOY_SERVER=1 environment variable. This will trigger a versioned server deployment which sets the origin to a preview deploy URL automatically.
5
Deploy the website and server to a hosting provider to access the routes in production on both native and web.
API route filenames cannot have platform-specific extensions. For example, hello+api.web.ts will not work.
Requests
Requests use the global, standard Request object.
export async function GET(request: Request, { post }: Record<string, string>) { // const postId = new URL(request.url).searchParams.get('post') // fetch data for 'post' return Response.json({ ... }); }
Request body
Use the request.json() function to access the request body. It automatically parses the body and returns the result.
export async function POST(request: Request) { const body = await request.json(); return Response.json({ ... }); }
Request query parameters
Query parameters can be accessed by parsing the request URL:
export async function GET(request: Request) { const url = new URL(request.url); const post = url.searchParams.get('post'); // fetch data for 'post' return Response.json({ ... }); }
Response
Responses use the global, standard Response object.
export function GET() { return Response.json({ hello: 'universe' }); }
Errors
For error cases, you can create Responses with any status code and response body.
export async function GET(request: Request, { post }: Record<string, string>) { if (!post) { return new Response('No post found', { status: 404, headers: { 'Content-Type': 'text/plain', }, }); } // fetch data for `post` return Response.json({ ... }); }
Making requests with an undefined method will automatically return 405: Method not allowed. If an error is thrown during the request, it will automatically return 500: Internal server error.
Runtime API
The server runtime API andexpo-serverare available in SDK 54 and later and require a deployed server for production use.
You can use the expo-server library to use several utilities and code patterns that work in any server-side Expo code. This includes utilities to get request metadata, for scheduling tasks, and for error handling.
- npx expo install expo-serverUsing expo-server is not limited to API routes and it can be used in any other server code as well, for example, in server middleware.
Error handling
You can abort a request and instead return an error Response by throwing a StatusError. This is a special Error instance that will be replaced with an HTTP response replacing the error itself.
import { StatusError } from 'expo-server'; export async function GET(request: Request, { post }: Record<string, string>) { if (!post) { throw new StatusError(404, 'No post found'); } // ... }
When composing your own server utilities and helpers, the StatusError is a more convenient way to handle exceptions, since throwing them interrupts any API functions and returns an error early.
StatusErrors accept a status code and an error message, which can also optionally be passed as a JSON, or Error object, and will always return a Response with a JSON body with an error key set to their error message.
This can be restrictive, and isn't suitable for all cases. Sometimes it might be beneficial to instead throw a Response object, which interrupts your logic as well, but replaces the resolved Response from your API route directly, without a StatusError wrapper. For example, this can be used to create redirect responses.
import { StatusError } from 'expo-server'; export async function GET(request: Request, { post }: Record<string, string>) { if (!post) { throw Response.redirect('https://expo.dev', 302); } // ... }
Request metadata
Requests typically carry most metadata you'll need in their headers. However, expo-server provides some helper functions to retrieve common values more easily.
Helper functions from expo-server return values that are scoped to the current Request. You can only call these functions in server-side code and only during ongoing requests.
A common value that you may need to access is the request's origin URL. The origin URL, typically transmitted on a request's Origin header, represents the URL that a user used to access your API route. This may differ from any internal deployment URL that your server sees when the request is being proxied. You can use expo-server's origin() helper method to access this value.
import { origin } from 'expo-server'; export async function GET(request: Request) { const target = new URL('/help', origin() ?? request.url); return Response.redirect('https://expo.dev', 302); }
Most runtimes that you deploy your server code to have a concept of environments, to differentiate between production or staging deployments. You can use expo-server's environment() helper to get an environment name. This value will differ depending on how you're running your server code.
import { environment } from 'expo-server'; export async function GET(request: Request) { const env = environment(); if (env === 'staging') { return Response.json({ isStaging: true }); } else if (!env) { return Response.json({ isProduction: true }); } else { return Response.json({ env }); } }
Task scheduling
In your request handlers, you may need to run asynchronous tasks in parallel to your server logic.
export async function GET(request: Request) { // This will delay the response: await pingAnalytics(...); const data = await fetchExampleData(...); return Response.json({ data }); }
In the above example, an await-ed function call delays the rest of the API route's execution. If we don't want to delay a Response, then await-ing this call isn't suitable. However, calling the function without await wouldn't guarantee that this task keeps a serverless function running.
Instead, you can use expo-server's runTask() helper function to run concurrent tasks. This is equivalent to the waitUntil() method that you see in service worker code or other serverless runtimes.
import { runTask } from 'expo-server'; export async function GET(request: Request) { // This will NOT delay the response: runTask(async () => { await pingAnalytics(...); }); const data = await fetchExampleData(...); return Response.json({ data }); }
With runTask, you have a compromise between await-ing and not await-ing asynchronous functions. They'll be run concurrently, and don't delay the API route's response or execution, but are also making sure the runtime is aware of them, and don't quit early.
However, sometimes you may want to delay a task until after the API route has returned a Response. In such cases, you might prefer not to execute the task if the API has rejected it. Additionally, you may want to run a function only after a time-sensitive task has been completed to prevent concurrent code from delaying computation-heavy tasks in your API route.
You can use expo-server's deferTask() helper function to schedule tasks to run after a Response has been resolved by your API route.
import { deferTask } from 'expo-server'; export async function GET(request: Request) { // This will run after this entire function resolves: deferTask(async () => { await pingAnalytics(...); }); const data = await fetchExampleData(...); return Response.json({ data }); }
Response headers
When structuring and splitting server logic into separate helper functions and files, it may be necessary to modify Response headers before a Response has been created.
For example, you may need to add metadata in server middleware to a Response before your API route code is running.
import { setResponseHeaders } from 'expo-server'; export default function middleware(request: Request) { // Rate limiters typically add a `Retry-After` header setResponseHeaders({ 'Retry-After': '3600' }); }
In the above example, a Retry-After header is added to a future Response that an API route may be creating. This can also be extended for authentication and cookies.
import { setResponseHeaders } from 'expo-server'; export default function middleware(request: Request) { // Append cookie to future response setResponseHeaders(headers => { headers.append('Set-Cookie', 'token=123; Secure'); }); }
Bundling
API Routes are bundled with Expo CLI and Metro bundler. They have access to all of the language features as your client code:
- TypeScript — types and tsconfig.json paths.
- Environment variables — server routes have access to all environment variables, not just the ones prefixed with
EXPO_PUBLIC_. - Node.js standard library — ensure that you are using the correct version of Node.js locally for your server environment.
- babel.config.js and metro.config.js support — settings work across both client and server code.
Security
Route handlers are executed in a sandboxed environment that is isolated from the client code. It means you can safely store sensitive data in the route handlers without exposing it to the client.
- Client code that imports code with a secret is included in the client bundle. It applies to all files in the app directory even though they are not a route handler file (such as suffixed with +api.ts).
- If the secret is in a <...>+api.ts file, it is not included in the client bundle. It applies to all files that are imported in the route handler.
- The secret stripping takes place in
expo/metro-configand requires it to be used in the metro.config.js.
Deployment
When you're ready to deploy to production, run the following command to create the server bundle in the dist directory (see the Expo CLI documentation for more details):
- npx expo export --platform webThis server can be tested locally with npx expo serve (available in Expo SDK 52 and later), visit the URL in a web browser or create a native build with the origin set to the local server URL.
You can deploy the server for production using EAS Hosting or another third-party service.
If you want to export API routes and skip generating a website version of your app, you can use the following command, which will generate a dist directory containing only the server code of your project.
- npx expo export --platform web --no-ssgEAS Hosting is the best way to deploy your Expo API routes and servers.
Native deployment
This is an experimental feature starting in SDK 52 and later. The process will be more automated and have better support in future versions.
Server features (API routes, and React Server Components) in Expo Router are centered around native implementations of window.location and fetch which point to the remote server. In development, we automatically point to the dev server running with npx expo start, but for production native builds to work you'll need to deploy the server to a secure host and set the origin property of the Expo Router Config Plugin.
When configured, features like relative fetch requests fetch('/my-endpoint') will automatically point to the server origin.
This deployment process can experimentally be automated to ensure correct versioning during native builds with the EXPO_UNSTABLE_DEPLOY_SERVER=1 environment variable.
Here's how to configure your native app to automatically deploy and link a versioned production server on build:
1
Ensure the origin field is NOT set in the app.json or in the expo.extra.router.origin field. Also, ensure you aren't using app.config.js as this is not supported with automatically linked deployments yet.
2
Setup EAS Hosting for the project by deploying once locally first.
- npx expo export -p web- eas deploy3
Set the EXPO_UNSTABLE_DEPLOY_SERVER environment variable in your .env file. This will be used to enable the experimental server deployment functionality during EAS Build.
EXPO_UNSTABLE_DEPLOY_SERVER=1
4
You're now ready to use automatic server deployment! Run the build command to start the process.
- eas buildYou can also run this locally with:
# Android- npx expo run:android --variant release# iOS- npx expo run:ios --configuration ReleaseNotes about automatic server deployment for native apps:
- Server failures may occur during the
Bundle JavaScriptphase of EAS Build if something was not setup correctly. - You can manually deploy the server and set the
originURL before building the app if you'd like. - Automatic deployment can be force skipped with the environment variable
EXPO_NO_DEPLOY=1. - Automatic deployment does not support dynamic app config (app.config.js and app.config.ts) files yet.
- Logs from the deployment will be written to
.expo/logs/deploy.log. - Deployment will not run in
EXPO_OFFLINEmode.
Testing the native production app locally
It can often be useful to test the production build against a local dev server to ensure everything is working as expected. This can speed up the debugging process substantially.
1
Export the production server:
- npx expo export2
Host the production server locally:
- npx expo serve3
Set the origin in the app.json's origin field. Ensure no generated value is in expo.extra.router.origin. This should be http://localhost:8081 (assuming npx expo serve is running on the default port).
{ "expo": { "plugins": [ [ "expo-router", { "origin": "http://localhost:8081" } ] ] } }
Remember to remove this origin value when deploying to production.
4
Build the app in release mode on to a simulator:
- EXPO_NO_DEPLOY=1 npx expo run:ios --configuration ReleaseYou should now see requests coming in to the local server. Use a tool like Proxyman to inspect network traffic for the simulator and gain better insight.
You can experimentally change the URL and quickly rebuild for iOS using the --unstable-rebundle flag. This will swap out the app.json and client assets for new ones, skipping the native rebuild.
For example, you can run eas deploy to get a new deployment URL, add it to the app.json, then run npx expo run:ios --unstable-rebundle --configuration Release to quickly rebuild the app with the new URL.
You will want to make a clean build before sending to the store to ensure no transient issues are present.
Hosting on third-party services
Theexpo-serverlibrary was added in SDK 54. Use@expo/serverfor older SDKs instead.
Every cloud hosting provider needs a custom adapter to support the Expo server runtime. The following third-party providers have unofficial or experimental support from the Expo team.
Before deploying to these providers, it may be good to be familiar with the basics of npx expo export command:
- dist is the default export directory for Expo CLI.
- Files in public directory are copied to dist on export.
- The
expo-serverpackage is a server-side runtime for exported Expo web and API route artifacts. expo-serverdoes not inflate environment variables from .env files. They are expected to load either by the hosting provider or the user.- Metro is not included in the server.
The expo-server library contains adapters for various providers and runtimes. Before proceeding with any of the below sections, install the expo-server library.
- npx expo install expo-serverBun
1
Export the website for production:
- bunx expo export -p web2
Write a server entry file that serves the static files and delegates requests to the server routes:
import { createRequestHandler } from 'expo-server/adapter/bun'; const CLIENT_BUILD_DIR = `${process.cwd()}/dist/client`; const SERVER_BUILD_DIR = `${process.cwd()}/dist/server`; const handleRequest = createRequestHandler({ build: SERVER_BUILD_DIR }); const port = process.env.PORT || 3000; Bun.serve({ port: process.env.PORT || 3000, async fetch(req) { const url = new URL(req.url); console.log('Request URL:', url.pathname); const staticPath = url.pathname === '/' ? '/index.html' : url.pathname; const file = Bun.file(CLIENT_BUILD_DIR + staticPath); if (await file.exists()) return new Response(await file.arrayBuffer()); return handleRequest(req); }, websocket, }); console.log(`Bun server running at http://localhost:${port}`);
4
Start the server with bun:
- bun run server.tsExpress
1
Install the required dependencies:
- npm i -D express compression morgan2
Export the website for production:
- npx expo export -p web3
Write a server entry file that serves the static files and delegates requests to the server routes:
#!/usr/bin/env node const path = require('path'); const { createRequestHandler } = require('expo-server/adapter/express'); const express = require('express'); const compression = require('compression'); const morgan = require('morgan'); const CLIENT_BUILD_DIR = path.join(process.cwd(), 'dist/client'); const SERVER_BUILD_DIR = path.join(process.cwd(), 'dist/server'); const app = express(); app.use(compression()); // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header app.disable('x-powered-by'); process.env.NODE_ENV = 'production'; app.use( express.static(CLIENT_BUILD_DIR, { maxAge: '1h', extensions: ['html'], }) ); app.use(morgan('tiny')); app.all( '/{*all}', createRequestHandler({ build: SERVER_BUILD_DIR, }) ); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Express server listening on port ${port}`); });
4
Start the server with node command:
- node server.tsNetlify
Third-party adapters are subject to breaking changes. We have no continuous tests against them.
1
Create a server entry file. All requests will be delegated through this middleware. The exact file location is important.
import path from 'node:path'; import { createRequestHandler } from 'expo-server/adapter/netlify'; export default createRequestHandler({ build: path.join(__dirname, '../../dist/server'), });
2
Create a Netlify configuration file at the root of your project to redirect all requests to the server function.
[build] command = "expo export -p web" functions = "netlify/functions" publish = "dist/client" [[redirects]] from = "/*" to = "/.netlify/functions/server" status = 404 [functions] # Include everything to ensure dynamic routes can be used. included_files = ["dist/server/**/*"] [[headers]] for = "/dist/server/_expo/functions/*" [headers.values] # Set to 60 seconds as an example. "Cache-Control" = "public, max-age=60, s-maxage=60"
3
After you have created the configuration files, you can build the website and functions with Expo CLI:
- npx expo export -p web4
Deploy to Netlify with the Netlify CLI.
# Install the Netlify CLI globally if needed.- npm install netlify-cli -g# Deploy the website.- netlify deployYou can now visit your website at the URL provided by Netlify CLI. Running netlify deploy --prod will publish to the production URL.
5
If you're using any environment variables or .env files, add them to Netlify. You can do this by going to the Site settings and adding them to the Build & deploy section.
Vercel
Third-party adapters are subject to breaking changes. We have no continuous tests against them.
1
Create a server entry file. All requests will be delegated through this middleware. The exact file location is important.
const { createRequestHandler } = require('expo-server/adapter/vercel'); module.exports = createRequestHandler({ build: require('path').join(__dirname, '../dist/server'), });
2
Create a Vercel configuration file (vercel.json) at the root of your project to redirect all requests to the server function.
{ "buildCommand": "expo export -p web", "outputDirectory": "dist/client", "functions": { "api/index.ts": { "runtime": "@vercel/node@5.1.8", "includeFiles": "dist/server/**" } }, "rewrites": [ { "source": "/(.*)", "destination": "/api/index" } ] }
The newer version of the vercel.json does not use routes and builds configuration options anymore, and serves your public assets from the dist/client output directory automatically.
{ "version": 2, "outputDirectory": "dist", "builds": [ { "src": "package.json", "use": "@vercel/static-build", "config": { "distDir": "dist/client" } }, { "src": "api/index.ts", "use": "@vercel/node", "config": { "includeFiles": ["dist/server/**"] } } ], "routes": [ { "handle": "filesystem" }, { "src": "/(.*)", "dest": "/api/index.ts" } ] }
The legacy version of the vercel.json needs a @vercel/static-build runtime to serve your assets from the dist/client output directory.
3
Note: This step only applies to users of the legacy version of the vercel.json. If you're using v3, you can skip this step.
After you have created the configuration files, add a vercel-build script to your package.json file and set it to expo export -p web.
4
Deploy to Vercel with the Vercel CLI.
# Install the Vercel CLI globally if needed.- npm install vercel -g# Build the website.- vercel build# Deploy the website.- vercel deploy --prebuiltYou can now visit your website at the URL provided by the Vercel CLI.
Known limitations
Several known features are not currently supported in the API Routes beta release.
No dynamic imports
API Routes currently work by bundling all code (minus the Node.js built-ins) into a single file. This means that you cannot use any external dependencies that are not bundled with the server. For example, a library such as sharp, which includes multiple platform binaries, cannot be used. This will be addressed in a future version.
ESM not supported
The current bundling implementation opts to be more unified than flexible. This means the limitation of native not supporting ESM is carried over to API Routes. All code will be transpiled down to Common JS (require/module.exports). However, we recommend you write API Routes using ESM regardless. This will be addressed in a future version.