Skip to content

Endpoints & API Routes

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.

src/pages/api/health.ts
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.

The handler receives a context with the request, params, cookies, and more:

src/pages/api/users/[id].ts
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)"]
An endpoint maps a method to a Response

Like pages, endpoints can be built once or run per request:

  • Static — with output: 'static', a GET endpoint runs at build time and its Response body is written to a file. Great for generating a rss.xml, a sitemap, or a static JSON API that doesn’t change per request. (Static endpoints only support GET.)
  • On-demand — add export const prerender = false (and an adapter) to run the endpoint per request, enabling POST/PUT/DELETE, request bodies, and live data.
// A dynamic endpoint that handles form posts:
export const prerender = false;

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:

src/pages/api/stream.ts
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 OK
Transfer-Encoding: chunked

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.

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 Response plumbing. 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.

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:

astro.config.mjs
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
});

When reviewing a generated endpoint, check for:

  • Does a dynamic or POST-handling endpoint actually set export const prerender = false? Without it (and under output: 'static'), only GET works and the response is frozen at build time — a POST handler 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 runtime undefined.
  • 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 no try/catch around 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

src/pages/api/report.ts
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.

How do you define an API endpoint in Astro?
A static (build-time) endpoint supports which method(s)?
What does this prove about the endpoint that produced it?
$ curl -sD - -o /dev/null http://127.0.0.1:4351/api/stream | grep -i "transfer-encoding"
Transfer-Encoding: chunked
A route has export const prerender = true (or the project has no adapter at all) and its handler reads context.clientAddress. What actually happens, proven in this lesson?
When should you use an Action instead of an endpoint?
What does an endpoint handler return?