Skip to content

Expo API Route Recipes

A React Native guide for coding agents. Also covers api route code examples, expo server routes, zod request validation, backend for frontend, ai provider adapter, rate limiting.

Expo Router API routes are a useful backend-for-frontend layer for validation, authorization, provider secrets, and response normalization. Native production builds need a deployed HTTPS server origin; the development server is not the production backend.

Validate JSON Before It Reaches Business Logic

Treat request.json() as unknown. Parse it once, return a stable public error, and pass the inferred type inward.

import { z } from "zod";

const createImageSchema = z.object({
  prompt: z.string().trim().min(3).max(1_000),
  style: z.enum(["line", "ink", "color"]),
});

export async function POST(request: Request): Promise<Response> {
  const user = await requireUser(request);
  if (!user) return Response.json({ code: "UNAUTHORIZED" }, { status: 401 });

  const parsed = createImageSchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return Response.json(
      { code: "INVALID_REQUEST", issues: parsed.error.flatten().fieldErrors },
      { status: 400 },
    );
  }

  const result = await imageProvider.generate({
    ...parsed.data,
    ownerId: user.id,
  });

  return Response.json({ id: result.id, status: result.status }, { status: 202 });
}

Do not echo arbitrary exceptions. Log a private error with an opaque request id, then give the app a stable code it can map to useful copy.

Authorize from a Trusted Credential

The server derives the user from a signed session; it does not trust userId in JSON.

type AuthenticatedUser = { id: string; role: "member" | "admin" };

async function requireUser(request: Request): Promise<AuthenticatedUser | null> {
  const authorization = request.headers.get("authorization");
  const token = authorization?.startsWith("Bearer ")
    ? authorization.slice("Bearer ".length)
    : null;

  if (!token) return null;
  try {
    return await verifyAccessToken(token); // verifies signature, issuer, audience, and expiry
  } catch {
    return null;
  }
}

An EXPO_PUBLIC_API_SECRET is not authentication. Anything under EXPO_PUBLIC_* is bundled into the app and can be extracted.

Hide AI Vendors Behind a Small Contract

Keep vendor request formats at the edge so routes and tests work with domain types.

type ImageRequest = {
  ownerId: string;
  prompt: string;
  style: "line" | "ink" | "color";
};

type ImageJob = {
  id: string;
  status: "queued" | "running";
};

export interface ImageProvider {
  generate(input: ImageRequest, options?: { signal?: AbortSignal }): Promise<ImageJob>;
}

export function createImageProvider(env: Record<string, string | undefined>): ImageProvider {
  const apiKey = env.IMAGE_PROVIDER_API_KEY;
  if (!apiKey) throw new Error("IMAGE_PROVIDER_API_KEY is required");

  return new HostedImageProvider({ apiKey });
}

Validate provider responses too. A typed SDK reduces mistakes, but network data is still a runtime boundary.

Make Retried Mutations Idempotent

Mobile networks fail after the server has accepted a request. Give each user action an idempotency key and enforce uniqueness server-side.

async function fingerprintRequest(input: unknown): Promise<string> {
  const bytes = new TextEncoder().encode(JSON.stringify(input));
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest), (byte) =>
    byte.toString(16).padStart(2, "0")
  ).join("");
}

const idempotencyKey = request.headers.get("idempotency-key");
if (!idempotencyKey) {
  return Response.json({ code: "IDEMPOTENCY_KEY_REQUIRED" }, { status: 400 });
}

const requestFingerprint = await fingerprintRequest(parsed.data);
const existing = await jobs.findByOwnerAndKey(user.id, idempotencyKey);
if (existing) {
  if (existing.requestFingerprint !== requestFingerprint) {
    return Response.json({ code: "IDEMPOTENCY_CONFLICT" }, { status: 409 });
  }
  return Response.json(existing, { status: 200 });
}

const created = await jobs.createOnce({
  ownerId: user.id,
  idempotencyKey,
  requestFingerprint,
  input: parsed.data,
});

Hash the validated schema output so its property order is deterministic. The database unique constraint on (ownerId, idempotencyKey) is the final guard; createOnce must return the stored record after a same-fingerprint race and reject a different fingerprint.

Normalize Errors for the App

Use a short public vocabulary:

export type ApiErrorCode =
  | "UNAUTHORIZED"
  | "FORBIDDEN"
  | "INVALID_REQUEST"
  | "IDEMPOTENCY_CONFLICT"
  | "LIMIT_REACHED"
  | "RATE_LIMITED"
  | "TEMPORARILY_UNAVAILABLE";

Retry network errors, 429, and selected 5xx responses with bounded backoff. Do not automatically retry validation, permission, or purchase mutations unless they are idempotent.

Deployment Checklist

  • Deploy routes to HTTPS and configure the Expo Router server origin for native builds.
  • Keep private provider keys only in the server environment.
  • Add authentication before rate limiting so limits attach to a stable user.
  • Cap body size and prompt length before expensive work.
  • Pass an AbortSignal where the provider supports cancellation.
  • Log latency and error class without logging secrets or sensitive prompts.

Sources

API deployment and routing behavior is checked against Expo’s API Routes and server middleware documentation. Validation follows Zod’s parsing model. The provider and job shapes are original abstractions inspired by common production Expo templates.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "expo-api-route-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems