Reading Request Data
Most useful endpoints do more than emit a fixed payload — they read input from the incoming request and respond accordingly. Astro hands every endpoint handler a standard web Request object plus a parsed url, so reading query strings, JSON, form submissions, and headers uses the exact same Web Platform APIs you’d find in a browser, a Service Worker, or any edge runtime. This page walks through each kind of input and the gotchas that come with them.
Where request data lives
Inside an endpoint, the APIContext exposes two objects you’ll reach for constantly: url (a parsed URL instance) for anything in the address bar, and request (the raw Request) for the body and headers. Dynamic path segments arrive separately on params.
// src/pages/api/inspect.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request, url, params }) => {
return new Response(
JSON.stringify({
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
params,
}),
{ headers: { 'Content-Type': 'application/json' } }
);
};
Reading query parameters
Query strings are parsed for you on url.searchParams, a URLSearchParams instance. Use get() for single values, getAll() for repeated keys, and has() to test presence. Everything comes back as a string (or null), so coerce numbers and booleans yourself.
// src/pages/api/search.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const page = Number(url.searchParams.get('page') ?? '1');
const tags = url.searchParams.getAll('tag');
return new Response(
JSON.stringify({ q, page, tags }),
{ headers: { 'Content-Type': 'application/json' } }
);
};
Output:
$ curl "http://localhost:4321/api/search?q=astro&page=2&tag=ssr&tag=islands"
{"q":"astro","page":2,"tags":["ssr","islands"]}
Query params are available in both static and server endpoints, but in a static build the values are whatever you set at build time — there is no live request. Read query strings at request time only in server (SSR) mode.
Parsing a JSON body
For APIs that accept application/json, call await request.json(). The body is a stream that can only be consumed once, so read it a single time and store the result. Wrap the call in a try/catch because malformed JSON throws.
// src/pages/api/users.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
let payload: { name?: string; email?: string };
try {
payload = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!payload.name || !payload.email) {
return new Response(JSON.stringify({ error: 'name and email required' }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ id: 'u_123', ...payload }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
};
Handling form submissions
HTML forms post either application/x-www-form-urlencoded or multipart/form-data. Both are read with await request.formData(), which returns a FormData object. Text fields come back as strings; file inputs come back as File objects with a name, type, and async .text() / .arrayBuffer() accessors.
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const message = data.get('message')?.toString();
const attachment = data.get('file'); // File | null
if (!email || !message) {
return new Response('Missing fields', { status: 400 });
}
const size = attachment instanceof File ? attachment.size : 0;
console.log(`Message from ${email} with ${size}-byte attachment`);
return new Response(null, { status: 303, headers: { Location: '/thanks' } });
};
The matching .astro form needs no client-side JavaScript — a plain form posts directly to the endpoint, preserving zero-JS-by-default:
---
// src/pages/contact.astro
---
<form method="POST" action="/api/contact" enctype="multipart/form-data">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="file" name="file" />
<button type="submit">Send</button>
</form>
A request body — JSON, text, or form data — can be read only once. Calling
request.json()afterrequest.formData()(or vice versa) throws “Body has already been consumed.” Decide which parser to use, often by inspecting theContent-Typeheader first.
Inspecting headers
Headers live on request.headers, a case-insensitive Headers map. Use them to branch on content type, read bearer tokens, or detect the client.
// src/pages/api/data.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const contentType = request.headers.get('content-type') ?? '';
const auth = request.headers.get('authorization');
if (!auth?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const body = contentType.includes('application/json')
? await request.json()
: Object.fromEntries(await request.formData());
return new Response(JSON.stringify({ ok: true, body }), {
headers: { 'Content-Type': 'application/json' },
});
};
Input source reference
| Source | Accessor | Returns | Notes |
|---|---|---|---|
| Query string | url.searchParams | URLSearchParams | Values are always strings |
| Route params | params | Record<string, string> | From [id].ts filenames |
| JSON body | await request.json() | parsed value | Throws on invalid JSON |
| Form / files | await request.formData() | FormData | Files are File instances |
| Raw text | await request.text() | string | Useful for webhooks/HMAC |
| Headers | request.headers | Headers | Case-insensitive lookup |
Best Practices
- Inspect
Content-Typebefore parsing so you call the right reader (jsonvs.formData) and never consume the body twice. - Wrap
request.json()intry/catchand return a400for malformed payloads instead of letting the handler crash. - Coerce and validate query params — they arrive as strings and may be missing; supply defaults with
??. - Return
422for well-formed but semantically invalid input, and401/403for auth failures, rather than a blanket400. - For webhooks that verify a signature, read the body with
request.text()first and validate the HMAC before parsing. - Remember query params and headers only carry live values in server (SSR) endpoints; static endpoints run at build time with no real request.