Endpoints & API Routes
A route that returns data, not a page
Section titled “A route that returns data, not a page”Not every route renders HTML. A file in src/pages/ with a .ts (or .js) extension is an API endpoint: instead of a template, it exports functions named after HTTP methods (GET, POST, PUT, DELETE) that each return a standard Response.
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = () => { return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, });};GET /api/health now returns JSON, proven against a running build:
$ curl -s http://127.0.0.1:4351/api/health{"ok":true}The APIRoute type gives you a typed context argument and a Response return — the same web-standard shape as everywhere else in Astro.
Reading params and the body
Section titled “Reading params and the body”The handler receives a context with the request, params, cookies, and more:
export const prerender = false; // dynamic + POST needs on-demand (or add getStaticPaths for GET-only)import type { APIRoute } from 'astro';
// Stand-in for your real database client — replace with your ORM/query layer.const db = { users: { find: async (id: string | undefined) => ({ id, name: 'Ava' }), create: async (data: any) => ({ id: crypto.randomUUID(), ...data }) } };
export const GET: APIRoute = async ({ params }) => { const user = await db.users.find(params.id); // [id] from the path if (!user) return new Response('Not found', { status: 404 }); return Response.json(user); // shorthand JSON response};
export const POST: APIRoute = async ({ request }) => { const body = await request.json(); // parse the request body const created = await db.users.create(body); return Response.json(created, { status: 201 });};flowchart LR req["GET or POST /api/..."] --> handler["exported handler (GET / POST / ...)"] handler --> read["read params, request body, cookies"] read --> resp["return a Response (JSON, file, status)"]
Static vs on-demand endpoints
Section titled “Static vs on-demand endpoints”Like pages, endpoints can be built once or run per request:
- Static — with
output: 'static', aGETendpoint runs at build time and itsResponsebody is written to a file. Great for generating arss.xml, asitemap, or a static JSON API that doesn’t change per request. (Static endpoints only supportGET.) - On-demand — add
export const prerender = false(and an adapter) to run the endpoint per request, enablingPOST/PUT/DELETE, request bodies, and live data.
// A dynamic endpoint that handles form posts:export const prerender = false;Streaming a response
Section titled “Streaming a response”Because a handler returns a real Response, it can stream instead of buffering the whole body in memory — construct it from a ReadableStream and Astro (through the adapter) forwards it as chunked output:
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for (const chunk of ['one\n', 'two\n', 'three\n']) { controller.enqueue(encoder.encode(chunk)); await new Promise((r) => setTimeout(r, 50)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/plain' } });};Proven against a running build — no Content-Length, and the server negotiates Transfer-Encoding: chunked on its own, exactly like any other streamed web response:
$ curl -sD - -o /dev/null http://127.0.0.1:4351/api/stream | grep -i "transfer-encoding\|^HTTP"HTTP/1.1 200 OKTransfer-Encoding: chunkedcontext.clientAddress: on-demand only
Section titled “context.clientAddress: on-demand only”context.clientAddress reads the requesting client’s IP from the adapter — which means it only exists where there is a live request to read it from. Reach for it in a prerendered route (with or without an adapter configured) and the real build failure is immediate:
---// @expect-error PrerenderClientAddressNotAvailable — Astro.clientAddress read on a prerendered route (src/pages/ip.astro)export const prerender = true;const ip = Astro.clientAddress;---[ERROR] PrerenderClientAddressNotAvailable: `Astro.clientAddress` cannot be used inside prerendered route src/pages/ip.astro. Error reference: https://docs.astro.build/en/reference/errors/prerender-client-address-not-available/Proven twice: identically whether the project has output: 'static' with no adapter at all, or output: 'server' with a Node adapter installed but that one route marked prerender = true — either way, there is no per-request connection for clientAddress to come from, because the page was rendered once, at build time. Move the route to on-demand (drop prerender = true, keep an adapter configured) and the same read works.
Endpoints vs Actions
Section titled “Endpoints vs Actions”Both run server code, but they solve different problems:
- An endpoint is a raw HTTP route you design yourself — you own the URL, method, status codes, and response shape. Reach for it for webhooks, third-party integrations, file downloads, or a public REST/JSON API.
- An Action (next lesson) is a type-safe function you call from your own client code with automatic input validation — no URL or
Responseplumbing. Reach for it for your app’s own mutations and forms.
Rule of thumb: public/external contract → endpoint; internal app logic called from your own frontend → Action.
Beyond endpoints: src/fetch.ts (Astro 7)
Section titled “Beyond endpoints: src/fetch.ts (Astro 7)”Endpoints give you one route at a time. Astro 7 stabilised advanced routing: a reserved file, src/fetch.ts, that sits in front of the whole request pipeline. It exports a standard fetch handler — the same (request) => Response shape that Cloudflare Workers, Deno, and Bun use — so you can run logic before Astro’s router, mount a Hono app with its middleware, or hand-roll routing for a slice of your URL space, then fall through to Astro for everything else.
Reach for it when middleware is not enough: proxying a legacy API under the same origin, wiring an existing Hono/Web-standard app into an Astro site, or shaping requests in a way the file-based router cannot express. For a single JSON route, a normal endpoint is still the right tool.
Because the filename is now reserved, an unrelated src/fetch.ts in an older project must be renamed — or point Astro elsewhere:
import { defineConfig } from 'astro/config';import node from '@astrojs/node';
export default defineConfig({ adapter: node({ mode: 'standalone' }), fetchFile: './src/router.ts', // or null to disable advanced routing});Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated endpoint, check for:
- Does a dynamic or
POST-handling endpoint actually setexport const prerender = false? Without it (and underoutput: 'static'), onlyGETworks and the response is frozen at build time — aPOSThandler that’s never actually reachable is a common generated-code mistake. - Does the handler read
context.clientAddress(rate limiting, geolocation, audit logs) on a route that’s prerendered? Proven above: that’s a hard build failure, not a runtimeundefined. - Is a large or slow-to-produce response buffered into one big string, when a
ReadableStream(proven above with real chunked output) would start sending bytes immediately? - Does the handler assume
request.json()always succeeds, with notry/catcharound malformed input? - Is business logic that’s really “my app calling my own backend” implemented as an endpoint with hand-rolled
fetch()calls on the client, when an Action (next lesson) would give the same result with automatic validation and no URL to maintain?
🐛 Spot the bug
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ clientAddress }) => {await logAccess(clientAddress);return Response.json({ ok: true });};Show the answer
There’s no export const prerender = false here, and no adapter is guaranteed to be configured for this route either — if this project (or just this route) ends up static, clientAddress is read on a route with no live request behind it. The real, proven failure from this exact situation is a build-time crash, not a quietly-undefined value: PrerenderClientAddressNotAvailable: Astro.clientAddress cannot be used inside prerendered route .... The fix is to make the on-demand intent explicit — export const prerender = false — and make sure the project has an adapter configured, so the route actually has a live request to read the client’s address from.