| name | expo-api-routes |
| description | Guidelines for creating API routes in Expo Router with EAS Hosting |
| version | 1.0.0 |
| license | MIT |
When to Use API Routes
Use API routes when you need:
- Server-side secrets — API keys, database credentials, or tokens that must never reach the client
- Database operations — Direct database queries that shouldn't be exposed
- Third-party API proxies — Hide API keys when calling external services (OpenAI, Stripe, etc.)
- Server-side validation — Validate data before database writes
- Webhook endpoints — Receive callbacks from services like Stripe or GitHub
- Rate limiting — Control access at the server level
- Heavy computation — Offload processing that would be slow on mobile
When NOT to Use API Routes
Avoid API routes when:
- Data is already public — Use direct fetch to public APIs instead
- No secrets required — Static data or client-safe operations
- Real-time updates needed — Use WebSockets or services like Supabase Realtime
- Simple CRUD — Consider Firebase, Supabase, or Convex for managed backends
- File uploads — Use direct-to-storage uploads (S3 presigned URLs, Cloudflare R2)
- Authentication only — Use Clerk, Auth0, or Firebase Auth instead
File Structure
API routes live in the app directory with +api.ts suffix:
app/
api/
hello+api.ts → GET /api/hello
users+api.ts → /api/users
users/[id]+api.ts → /api/users/:id
(tabs)/
index.tsx
Basic API Route
export function GET(request: Request) {
return Response.json({ message: 'Hello from Expo!' });
}
HTTP Methods
Export named functions for each HTTP method:
export function GET(request: Request) {
return Response.json({ items: [] });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ created: body }, { status: 201 });
}
export async function PUT(request: Request) {
const body = await request.json();
return Response.json({ updated: body });
}
export async function DELETE(request: Request) {
return new Response(null, { status: 204 });
}
Dynamic Routes
export function GET(request: Request, { id }: { id: string }) {
return Response.json({ userId: id });
}
Request Handling
Query Parameters
export function GET(request: Request) {
const url = new URL(request.url);
const page = url.searchParams.get('page') ?? '1';
const limit = url.searchParams.get('limit') ?? '10';
return Response.json({ page, limit });
}
Headers
export function GET(request: Request) {
const auth = request.headers.get('Authorization');
if (!auth) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return Response.json({ authenticated: true });
}
JSON Body
export async function POST(request: Request) {
const { email, password } = await request.json();
if (!email || !password) {
return Response.json({ error: 'Missing fields' }, { status: 400 });
}
return Response.json({ success: true });
}
Environment Variables
Use process.env for server-side secrets:
export async function POST(request: Request) {
const { prompt } = await request.json();
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
}),
});
const data = await response.json();
return Response.json(data);
}
Set environment variables:
- Local: Create
.env file (never commit)
- EAS Hosting: Use
eas env:create or Expo dashboard
CORS Headers
Add CORS for web clients:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export function OPTIONS() {
return new Response(null, { headers: corsHeaders });
}
export function GET() {
return Response.json({ data: 'value' }, { headers: corsHeaders });
}
Error Handling
export async function POST(request: Request) {
try {
const body = await request.json();
return Response.json({ success: true });
} catch (error) {
console.error('API error:', error);
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}
Testing Locally
Start the development server with API routes:
bunx expo serve
This starts a local server at http://localhost:8081 with full API route support.
Test with curl:
curl http://localhost:8081/api/hello
curl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}'
Deployment to EAS Hosting
Prerequisites
bun install -g eas-cli
eas login
Deploy
eas deploy
This builds and deploys your API routes to EAS Hosting (Cloudflare Workers).
Environment Variables for Production
eas env:create --name OPENAI_API_KEY --value sk-xxx --environment production
Custom Domain
Configure in eas.json or Expo dashboard.
EAS Hosting Runtime (Cloudflare Workers)
API routes run on Cloudflare Workers. Key limitations:
Missing/Limited APIs
- No Node.js filesystem —
fs module unavailable
- No native Node modules — Use Web APIs or polyfills
- Limited execution time — 30 second timeout for CPU-intensive tasks
- No persistent connections — WebSockets require Durable Objects
- fetch is available — Use standard fetch for HTTP requests
Use Web APIs Instead
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('data'));
const response = await fetch('https://api.example.com');
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
});
Database Options
Since filesystem is unavailable, use cloud databases:
- Cloudflare D1 — SQLite at the edge
- Turso — Distributed SQLite
- PlanetScale — Serverless MySQL
- Supabase — Postgres with REST API
- Neon — Serverless Postgres
Example with Turso:
import { createClient } from '@libsql/client/web';
const db = createClient({
url: process.env.TURSO_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
export async function GET() {
const result = await db.execute('SELECT * FROM users');
return Response.json(result.rows);
}
Calling API Routes from Client
const response = await fetch('/api/hello');
const data = await response.json();
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'John' }),
});
Common Patterns
Authentication Middleware
export async function requireAuth(request: Request) {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return { userId: '123' };
}
import { requireAuth } from '../../utils/auth';
export async function GET(request: Request) {
const { userId } = await requireAuth(request);
return Response.json({ userId });
}
Proxy External API
export async function GET(request: Request) {
const url = new URL(request.url);
const city = url.searchParams.get('city');
const response = await fetch(
`https://api.weather.com/v1/current?city=${city}&key=${process.env.WEATHER_API_KEY}`
);
return Response.json(await response.json());
}
Rules
- NEVER expose API keys or secrets in client code
- ALWAYS validate and sanitize user input
- Use proper HTTP status codes (200, 201, 400, 401, 404, 500)
- Handle errors gracefully with try/catch
- Keep API routes focused — one responsibility per endpoint
- Use TypeScript for type safety
- Log errors server-side for debugging