Skill v1.0.0
currentTrusted Publisher100/100name: 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/hellousers+api.ts → /api/usersusers/[id]+api.ts → /api/users/:id(tabs)/index.tsx
Basic API Route
// app/api/hello+api.tsexport function GET(request: Request) {return Response.json({ message: "Hello from Expo!" });}
HTTP Methods
Export named functions for each HTTP method:
// app/api/items+api.tsexport 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
// app/api/users/[id]+api.tsexport 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:
// app/api/ai+api.tsexport 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
.envfile (never commit) - EAS Hosting: Use
eas env:createor 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();// Process...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:
npx expo serve
This starts a local server at http://localhost:8081 with full API route support.
Test with curl:
curl http://localhost:8081/api/hellocurl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}'
Deployment to EAS Hosting
Prerequisites
npm install -g eas-clieas login
Deploy
eas deploy
This builds and deploys your API routes to EAS Hosting (Cloudflare Workers).
Environment Variables for Production
# Create a secreteas env:create --name OPENAI_API_KEY --value sk-xxx --environment production# Or use the Expo dashboard
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 —
fsmodule 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
// Use Web Crypto instead of Node cryptoconst hash = await crypto.subtle.digest("SHA-256",new TextEncoder().encode("data"));// Use fetch instead of node-fetchconst response = await fetch("https://api.example.com");// Use Response/Request (already available)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:
// app/api/users+api.tsimport { 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
// From React Native componentsconst response = await fetch("/api/hello");const data = await response.json();// With bodyconst response = await fetch("/api/users", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({ name: "John" }),});
Common Patterns
Authentication Middleware
// utils/auth.tsexport 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" },});}// Verify token...return { userId: "123" };}// app/api/protected+api.tsimport { requireAuth } from "../../utils/auth";export async function GET(request: Request) {const { userId } = await requireAuth(request);return Response.json({ userId });}
Proxy External API
// app/api/weather+api.tsexport 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