plugins/src/expo/skills/expo-api-routes/SKILL.md
Guidelines for creating API routes in Expo Router with EAS Hosting
npx skillsauth add codyswanngt/lisa expo-api-routesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use API routes when you need:
Avoid API routes when:
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
// app/api/hello+api.ts
export function GET(request: Request) {
return Response.json({ message: "Hello from Expo!" });
}
Export named functions for each HTTP method:
// app/api/items+api.ts
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 });
}
// app/api/users/[id]+api.ts
export function GET(request: Request, { id }: { id: string }) {
return Response.json({ userId: id });
}
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 });
}
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 });
}
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 });
}
Use process.env for server-side secrets:
// app/api/ai+api.ts
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:
.env file (never commit)eas env:create or Expo dashboardAdd 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 });
}
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 });
}
}
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/hello
curl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}'
npm install -g eas-cli
eas login
eas deploy
This builds and deploys your API routes to EAS Hosting (Cloudflare Workers).
# Create a secret
eas env:create --name OPENAI_API_KEY --value sk-xxx --environment production
# Or use the Expo dashboard
Configure in eas.json or Expo dashboard.
API routes run on Cloudflare Workers. Key limitations:
fs module unavailable// Use Web Crypto instead of Node crypto
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode("data")
);
// Use fetch instead of node-fetch
const response = await fetch("https://api.example.com");
// Use Response/Request (already available)
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
Since filesystem is unavailable, use cloud databases:
Example with Turso:
// app/api/users+api.ts
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);
}
// From React Native components
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL!;
const response = await fetch(`${API_BASE_URL}/api/hello`);
const data = await response.json();
// With body
const response = await fetch(`${API_BASE_URL}/api/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "John" }),
});
// utils/auth.ts
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" },
});
}
// Verify token...
return { userId: "123" };
}
// app/api/protected+api.ts
import { requireAuth } from "../../utils/auth";
export async function GET(request: Request) {
const { userId } = await requireAuth(request);
return Response.json({ userId });
}
// app/api/weather+api.ts
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());
}
development
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.