Server Endpoints & HTTP Methods
Astro endpoints aren’t limited to serving static files at build time. When you run on a server adapter, a single .ts file in src/pages/ can respond to GET, POST, PUT, PATCH, and DELETE requests, letting you build a JSON API that lives right beside your pages. Because Astro ships zero JavaScript to the client by default, these endpoints are the natural place to put server logic — form submissions, database writes, webhooks — without leaking secrets to the browser. This page shows how to write a real REST-style API using SSR endpoints.
Enabling server-side rendering
Multi-method endpoints require a runtime, so you need an SSR adapter and on-demand rendering. Static endpoints only ever run at build time and can export a GET returning prerendered output, but POST/PUT/DELETE need a live server.
npx astro add node
Then either set output to server-first or opt the endpoint into on-demand rendering per file:
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
});
In Astro 5 the default
outputis"static", and you opt individual routes into SSR withexport const prerender = false;. Useoutput: "server"only when most of your site is dynamic.
Anatomy of a method handler
Each HTTP method maps to an exported function whose name is the uppercase verb. Every handler receives an APIContext and must return a standard Response. The context exposes the request, parsed params, url, cookies, redirect(), and locals.
// src/pages/api/health.ts
import type { APIRoute } from "astro";
export const prerender = false;
export const GET: APIRoute = ({ url }) => {
return new Response(
JSON.stringify({ status: "ok", path: url.pathname }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
Output:
$ curl -s http://localhost:4321/api/health
{"status":"ok","path":"/api/health"}
A full CRUD endpoint
Below is a single file backing /api/tasks with read and create, plus a dynamic [id].ts for update and delete. The handlers parse the body, validate input, and return correct status codes.
// src/pages/api/tasks/index.ts
import type { APIRoute } from "astro";
import { db } from "../../../lib/db";
export const prerender = false;
export const GET: APIRoute = async () => {
const tasks = await db.task.findMany();
return Response.json(tasks);
};
export const POST: APIRoute = async ({ request }) => {
const data = await request.json().catch(() => null);
if (!data || typeof data.title !== "string" || !data.title.trim()) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const task = await db.task.create({
data: { title: data.title.trim(), done: false },
});
return Response.json(task, { status: 201 });
};
// src/pages/api/tasks/[id].ts
import type { APIRoute } from "astro";
import { db } from "../../../lib/db";
export const prerender = false;
export const PUT: APIRoute = async ({ params, request }) => {
const data = await request.json().catch(() => null);
if (!data) {
return Response.json({ error: "invalid body" }, { status: 400 });
}
const updated = await db.task.update({
where: { id: params.id },
data: { title: data.title, done: Boolean(data.done) },
});
return Response.json(updated);
};
export const DELETE: APIRoute = async ({ params }) => {
await db.task.delete({ where: { id: params.id } });
return new Response(null, { status: 204 });
};
Note the modern Response.json() helper — it sets Content-Type: application/json and serializes for you, so you rarely need to build a Response by hand anymore.
Reading the request body
The request is a standard web Request, so you read the body with whichever parser matches the content type sent by the client.
| Content type | Reader | Returns |
|---|---|---|
application/json | await request.json() | Parsed object |
application/x-www-form-urlencoded / multipart/form-data | await request.formData() | FormData |
text/plain | await request.text() | String |
| Any (raw) | await request.arrayBuffer() | ArrayBuffer |
// src/pages/api/contact.ts
import type { APIRoute } from "astro";
export const prerender = false;
export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData();
const email = form.get("email");
if (typeof email !== "string" || !email.includes("@")) {
return new Response("Invalid email", { status: 422 });
}
// persist, then send the browser to a thank-you page
return redirect("/thanks", 303);
};
Returning correct status codes
Map outcomes to HTTP semantics so clients (and your own islands) can react predictably.
| Situation | Status | Helper |
|---|---|---|
| Resource fetched | 200 | Response.json(data) |
| Resource created | 201 | Response.json(data, { status: 201 }) |
| Success, no content | 204 | new Response(null, { status: 204 }) |
| Bad input | 400 / 422 | Response.json(err, { status: 400 }) |
| Method not allowed | 405 | add an ALL handler |
You can also export an ALL handler to catch any verb you haven’t defined:
export const ALL: APIRoute = () =>
new Response("Method Not Allowed", { status: 405 });
Requests to an undefined method automatically receive a
404unless you provideALL. Define it when you want a clean405instead.
Calling the endpoint from an island
Endpoints pair perfectly with islands: render the shell with zero JS, then hydrate only the interactive part that talks to your API.
---
// src/components/AddTask.astro
---
<form id="add">
<input name="title" required />
<button>Add</button>
</form>
<script>
const form = document.querySelector<HTMLFormElement>("#add")!;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = new FormData(form).get("title");
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
location.reload();
});
</script>
Best practices
- Add
export const prerender = false;to every dynamic endpoint when your site is otherwise static — forgetting it ships an empty build-time response. - Always wrap
request.json()in.catch(); malformed bodies throw and would otherwise crash the handler with a 500. - Return precise status codes (
201,204,400,422) so clients and caches behave correctly. - Validate and narrow input types server-side — never trust the request body even from your own forms.
- Prefer
Response.json()over hand-builtResponseobjects for cleaner, less error-prone code. - Keep secrets (DB URLs, API keys) in endpoints, not in
client:*components, to preserve Astro’s zero-JS-by-default safety. - Use
redirect(url, 303)after a successful formPOSTto avoid duplicate submissions on refresh.