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
originfor 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
AbortSignalwhere 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.