Skip to content
Astro as endpoints 5 min read

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 output is "static", and you opt individual routes into SSR with export const prerender = false;. Use output: "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 typeReaderReturns
application/jsonawait request.json()Parsed object
application/x-www-form-urlencoded / multipart/form-dataawait request.formData()FormData
text/plainawait 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.

SituationStatusHelper
Resource fetched200Response.json(data)
Resource created201Response.json(data, { status: 201 })
Success, no content204new Response(null, { status: 204 })
Bad input400 / 422Response.json(err, { status: 400 })
Method not allowed405add 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 404 unless you provide ALL. Define it when you want a clean 405 instead.

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-built Response objects 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 form POST to avoid duplicate submissions on refresh.
Last updated June 14, 2026
Was this helpful?