Type something to search...
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or POST. The function receives a context object with the request, URL, params and cookies, and returns a standard Response. In a static build, Astro calls GET once at build time and saves the result as a file, which is ideal for JSON feeds or generated data. With an adapter and export const prerender = false, the same file becomes a live server route that runs on every request and can accept form data, JSON bodies and any method you export.

Endpoints are Astro's answer to API routes in other frameworks, and they use the same file-based routing as pages. In this article you'll learn how to create static file endpoints, how to generate many of them with getStaticPaths(), how to build a small JSON API with GET, POST and DELETE, how to read query strings and request bodies, how to return proper status codes and headers, what the ALL export and automatic HEAD handling do, and how Astro's built-in origin check affects requests.

Static File Endpoints

The simplest endpoint generates a file at build time. The file name should include the extension you want, because Astro only strips the .ts:

// src/pages/site.json.ts
import type { APIRoute } from "astro";

export const GET: APIRoute = () => {
  return Response.json({
    name: "My Astro site",
    builtAt: new Date().toISOString(),
  });
};

astro build writes the response body to dist/site.json:

{"name":"My Astro site","builtAt":"2026-10-24T09:43:26.673Z"}

The timestamp is frozen at build time, which shows the key point: a static endpoint runs once, and the output is a plain file served by your host. Typical uses include:

  • JSON for client scripts: a search index or a list of posts that a small script fetches.
  • Feeds and sitemaps: rss.xml.ts or a custom sitemap.xml.ts. Generating a feed is covered in how to generate an RSS feed in Astro.
  • Generated assets: a .png or .txt file assembled from data.

Use the APIRoute type (or the satisfies APIRoute pattern) to get types for the context object. Response.json() is the standard Web API shortcut for a JSON body with the right Content-Type header.

URLs with Extensions and Trailing Slashes

Since Astro 6, endpoints whose URL ends in a file extension can only be reached without a trailing slash, whatever your trailingSlash setting. On my test server /site.json worked and /site.json/ returned a 404. Link to these files without a trailing slash.

Dynamic Static Endpoints

Endpoints support the same bracket syntax as pages. Add a getStaticPaths() export to generate one file per entry:

// src/pages/api/posts/[id].json.ts
import type { APIRoute, GetStaticPaths } from "astro";

const posts = [
  { id: "hello", title: "Hello world" },
  { id: "routing", title: "Routing in Astro" },
];

export const getStaticPaths = (() => {
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}) satisfies GetStaticPaths;

export const GET: APIRoute = ({ props }) => {
  return Response.json(props.post);
};

The build generated:

/api/posts/hello.json
/api/posts/routing.json

with hello.json containing {"id":"hello","title":"Hello world"}. props works here just as it does for pages, but only for prerendered endpoints. For more on this pattern, see how to create dynamic routes with getStaticPaths.

Server Endpoints

To handle requests at runtime, such as accepting a form or querying a database, the endpoint has to run on a server. Install an adapter, then opt the endpoint out of prerendering:

// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
});

In the default static output, add export const prerender = false to each server endpoint. With output: "server", endpoints are on demand by default. The trade-offs between the two modes are covered in static vs on-demand rendering in Astro.

A Small JSON API

The rest of this article builds a to-do API. The data lives in a module so several endpoints can share it. It's an in-memory array to keep the example self-contained; in a real project this would be a database:

// src/lib/todos.ts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

const todos: Todo[] = [{ id: 1, title: "Write the post", done: false }];
let nextId = 2;

export function listTodos(): Todo[] {
  return todos;
}

export function addTodo(title: string): Todo {
  const todo = { id: nextId++, title, done: false };
  todos.push(todo);
  return todo;
}

export function removeTodo(id: number): boolean {
  const index = todos.findIndex((t) => t.id === id);
  if (index === -1) return false;
  todos.splice(index, 1);
  return true;
}

GET with Query Parameters and POST with a JSON Body

// src/pages/api/todos.ts
import type { APIRoute } from "astro";
import { addTodo, listTodos } from "../../lib/todos";

export const prerender = false;

export const GET: APIRoute = ({ url }) => {
  const status = url.searchParams.get("status");
  let todos = listTodos();
  if (status === "open") todos = todos.filter((t) => !t.done);
  if (status === "done") todos = todos.filter((t) => t.done);
  return Response.json(todos);
};

export const POST: APIRoute = async ({ request }) => {
  const type = request.headers.get("content-type") ?? "";
  if (!type.startsWith("application/json")) {
    return Response.json({ error: "Expected JSON" }, { status: 415 });
  }

  const body = await request.json().catch(() => null);
  const title = typeof body?.title === "string" ? body.title.trim() : "";

  if (!title) {
    return Response.json({ error: "title is required" }, { status: 400 });
  }

  const todo = addTodo(title);
  return Response.json(todo, {
    status: 201,
    headers: { Location: `/api/todos/${todo.id}` },
  });
};

A few things to note:

  • url is already parsed: the context's url is a URL object, so url.searchParams gives you the query string without parsing request.url yourself.
  • request is a real Request: in on-demand endpoints you can call request.json(), request.formData() or request.text(). In static endpoints only request.url is meaningful.
  • Validate everything: the .catch(() => null) handles invalid JSON, and the type check rejects a missing or non-string title.
  • Use the right status: 201 Created with a Location header for a new resource, 400 for bad input and 415 for the wrong content type.

Testing against the built server:

curl -s -i -X POST http://localhost:4332/api/todos \
  -H 'content-type: application/json' \
  -d '{"title":"Test the endpoint"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/todos/2
{"id":2,"title":"Test the endpoint","done":false}

And the error cases:

POST {}                        -> {"error":"title is required"} 400
POST title=x (form encoded)    -> {"error":"Expected JSON"} 415
GET  /api/todos?status=open    -> [{"id":1,...},{"id":2,...}]

Dynamic Server Endpoints with DELETE and ALL

On-demand endpoints read route parameters from params without getStaticPaths():

// src/pages/api/todos/[id].ts
import type { APIRoute } from "astro";
import { listTodos, removeTodo } from "../../../lib/todos";

export const prerender = false;

export const GET: APIRoute = ({ params }) => {
  const todo = listTodos().find((t) => t.id === Number(params.id));
  if (!todo) {
    return Response.json({ error: "Not found" }, { status: 404 });
  }
  return Response.json(todo);
};

export const DELETE: APIRoute = ({ params }) => {
  const removed = removeTodo(Number(params.id));
  return new Response(null, { status: removed ? 204 : 404 });
};

export const ALL: APIRoute = ({ request }) => {
  return Response.json(
    { error: `${request.method} is not supported` },
    { status: 405, headers: { Allow: "GET, DELETE" } },
  );
};

The full sequence against the server:

GET    /api/todos/2  -> {"id":2,"title":"Test the endpoint","done":false} 200
DELETE /api/todos/2  -> 204
GET    /api/todos/2  -> {"error":"Not found"} 404
PATCH  /api/todos/1  -> 405, allow: GET, DELETE, {"error":"PATCH is not supported"}

Params are always strings, so convert them with Number() when your IDs are numeric. Since params.id comes from the URL, treat it as untrusted input.

How Astro Picks the Handler

Astro matches the request method to your exports:

RequestExportsWhat runs
GETGETGET
POSTGET, POSTPOST
PATCHGET, DELETE, ALLALL
HEADGET onlyGET, with the body removed
PUTGET, POSTNo match: your 404 page

Two details from testing:

  • Automatic HEAD: if you define GET but not HEAD, Astro calls GET and strips the body. curl -I /api/todos returned 200 OK with content-type: application/json.
  • No match means 404: a PUT to /api/todos, which has no PUT or ALL export, returned a 404. If you'd rather send 405 Method Not Allowed, add an ALL export as in the example above.

The Context Object

Every handler receives the same context that pages get through the Astro global. The parts you'll use most in endpoints:

  • request: the incoming Request, with headers and body.
  • url: the parsed request URL.
  • params: route parameters from the file name.
  • props: data from getStaticPaths(), prerendered endpoints only.
  • cookies: read and set cookies with cookies.get() and cookies.set(). See cookies in Astro.
  • locals: data attached by middleware, such as the current user.
  • redirect(): returns a redirect response, for example redirect("/login", 303).
  • site: your configured site URL.

For redirect patterns built on endpoints, such as short links, see how to set up redirects in Astro.

Security: The Built-In Origin Check

On-demand routes have CSRF protection enabled by default through security.checkOrigin. For unsafe methods such as POST, PUT, PATCH and DELETE, with no content type or a form-style content type (application/x-www-form-urlencoded, multipart/form-data or text/plain), Astro checks the browser's Sec-Fetch-Site header, or Origin when that's missing. Cross-origin requests are rejected before your code runs:

curl -s -w ' %{http_code}\n' -X POST http://localhost:4332/api/todos \
  -H 'Origin: https://evil.example' \
  -H 'content-type: text/plain' \
  -d 'x'
Cross-site POST form submissions are forbidden 403

JSON requests aren't covered by this check, because browsers can't send them cross-origin without a CORS preflight. If you open an endpoint to other origins, add your own CORS headers and authentication deliberately. Leave checkOrigin on unless you have a specific reason to turn it off.

Endpoints or Actions?

Astro also has actions, which are type-safe server functions you call from client code or HTML forms, with input validation built in. A rough guide:

  • Use endpoints for public URLs: JSON APIs consumed by other services, webhooks, feeds, files, redirects and anything that needs a specific HTTP shape.
  • Use actions for your own site's forms and client interactions, where validation and typed results save you boilerplate.

Actions get a full article in Astro actions.


FAQ: API Endpoints in Astro

In src/pages, like pages. Any .js or .ts file there that exports a function named after an HTTP method, such as GET or POST, becomes an endpoint at the URL that matches its path.

Not for GET in a static build: Astro runs it at build time and saves the output as a file. To accept POST requests or run code per request, you need an adapter and prerender set to false.

In an on-demand endpoint, call await request.json() on the context's request. Wrap it in a try/catch or .catch() so invalid JSON returns a 400 instead of throwing.

Use the url property from the context, which is a URL object, and call url.searchParams.get("name"). It's only meaningful in on-demand endpoints, since static ones run once at build time.

Astro returns your 404 page. Export an ALL function to handle any method without its own export, for example to send a 405 response with an Allow header. HEAD requests fall back to GET automatically.

Astro's checkOrigin protection rejects cross-origin form-style POST, PUT, PATCH and DELETE requests to on-demand routes. Send requests from the same origin, or review the security.checkOrigin setting if you truly need cross-origin access.


Conclusion

Astro endpoints are files in src/pages/ that export HTTP method functions and return a standard Response. In a static build they generate files such as JSON and XML at build time, and with getStaticPaths() one file can produce many outputs. With an adapter and prerender = false, the same API becomes a live server route that reads query strings, JSON bodies, cookies and params on every request.

Treat endpoints like any other API: validate input, return accurate status codes and headers, decide what happens to unsupported methods, and keep the built-in origin check switched on. With those habits, a handful of files in src/pages/api/ gives you a small, standards-based backend alongside your site.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to add user authentication to an Astro site?

How to add user authentication to an Astro site?

To add user authentication to an Astro site, install an adapter so pages can run on demand, choose an auth library such as Better Auth or a h

Dive Deeper