Skip to content
Astro as endpoints 5 min read

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() after request.formData() (or vice versa) throws “Body has already been consumed.” Decide which parser to use, often by inspecting the Content-Type header 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

SourceAccessorReturnsNotes
Query stringurl.searchParamsURLSearchParamsValues are always strings
Route paramsparamsRecord<string, string>From [id].ts filenames
JSON bodyawait request.json()parsed valueThrows on invalid JSON
Form / filesawait request.formData()FormDataFiles are File instances
Raw textawait request.text()stringUseful for webhooks/HMAC
Headersrequest.headersHeadersCase-insensitive lookup

Best Practices

  • Inspect Content-Type before parsing so you call the right reader (json vs. formData) and never consume the body twice.
  • Wrap request.json() in try/catch and return a 400 for 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 422 for well-formed but semantically invalid input, and 401/403 for auth failures, rather than a blanket 400.
  • 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.
Last updated June 14, 2026
Was this helpful?