Endpoints SSR: verbos, cuerpo de la petición y APIContext
Cuando un endpoint deja de hornearse y empieza a atender: con prerender false y un adaptador, el mismo módulo .ts se ejecuta en cada petición y puede responder a GET, POST, PUT, PATCH y DELETE con handlers distintos. Cómo leer el cuerpo entrante con request.json y request.formData, qué contiene el objeto APIContext que recibe cada handler —request, params, url, cookies, clientAddress, redirect— y por qué un endpoint bajo demanda es una función de una petición a una respuesta, ni más ni menos.
De horneado a atendido: prerender false
En un proyecto estático toda ruta se hornea. Para que un endpoint se ejecute por petición en lugar de en el build, lo marcas explícitamente y añades un adaptador de servidor —Node, Cloudflare, Vercel— que le dé un runtime donde correr:
// src/pages/api/eco.tsimport type { APIRoute } from 'astro';export const prerender = false; // este endpoint se ejecuta por peticionexport const GET: APIRoute = ({ url }) => { const nombre = url.searchParams.get('nombre') ?? 'mundo'; return Response.json({ saludo: `hola ${nombre}` });};Con prerender = false, este GET ya no produce un fichero: cada visita a /api/eco?nombre=ana lo ejecuta de nuevo, lee url.searchParams en vivo y responde al vuelo. La misma constante prerender que gobernaba las páginas dinámicas gobierna los endpoints, porque —insistimos en la idea— páginas y endpoints son la misma máquina con distinto codominio. Lo que cambia entre estático y bajo demanda no es la sintaxis del handler, sino cuándo se evalúa y qué tiene disponible al hacerlo.
Un handler por verbo
El método HTTP es el vocabulario con el que un cliente declara su intención sobre un recurso. Astro lo respeta al pie de la letra: exportas una función con el nombre del verbo en mayúsculas y Astro la despacha cuando llega una petición de ese método. Un mismo fichero puede exportar varios, cada uno atendiendo su verbo.
// src/pages/api/notas.tsimport type { APIRoute } from 'astro';export const prerender = false;export const GET: APIRoute = async () => { const notas = await db.listar(); return Response.json(notas);};export const POST: APIRoute = async ({ request }) => { const datos = await request.json(); const nota = await db.crear(datos); return Response.json(nota, { status: 201 });};export const DELETE: APIRoute = async ({ url }) => { const id = url.searchParams.get('id'); await db.borrar(id); return new Response(null, { status: 204 });};Fíjate en los estados: POST que crea devuelve 201 Created; DELETE que no tiene nada que devolver, 204 No Content con cuerpo nulo. Esos códigos no son adorno —son la parte de la respuesta que un programa entiende sin leer el cuerpo—. Si un método no tiene handler, Astro responde 405 Method Not Allowed con la cabecera Allow; y si quieres una sola función que atienda cualquier verbo, exportas ALL y ramificas dentro según request.method.
Leer el cuerpo: json, formData, text
Un GET no trae cuerpo; su entrada son la URL y las cabeceras. Pero POST, PUT y PATCH sí cargan datos, y el objeto request —un Request estándar de la plataforma— ofrece varios lectores según el tipo de contenido. Cada uno es asíncrono y consume el flujo una sola vez, así que eliges uno por petición.
export const POST: APIRoute = async ({ request }) => { const tipo = request.headers.get('content-type') ?? ''; if (tipo.includes('application/json')) { const cuerpo = await request.json(); // objeto ya parseado return Response.json({ recibido: cuerpo }); } if (tipo.includes('form')) { const form = await request.formData(); // FormData const email = form.get('email'); return Response.json({ email }); } const texto = await request.text(); // texto crudo return new Response(texto);};request.json parsea JSON a un objeto; request.formData devuelve un FormData con los campos de un formulario —o de una subida multipart—; request.text te da el cuerpo crudo, útil cuando necesitas los bytes exactos, por ejemplo para verificar una firma. Ninguno es mágico de Astro: son la API Request/Response que comparten Deno, los Service Workers y los runtimes del borde. Lo que aprendes aquí es transferible mucho más allá del framework.
El objeto APIContext
Cada handler recibe un único argumento: el APIContext, el mismo contexto que en una página está detrás de Astro. Desestructurarlo es leer el estado de la petición actual. Sus campos más usados:
Hay más en ese contexto —redirect para responder un 3xx con destino, rewrite para servir otra ruta sin cambiar la URL, site con la URL base, locals para pasar datos desde un middleware—. Todos comparten una naturaleza: son lo que esta petición trae o permite, disponible porque ahora hay una petición real. En estático ese contexto estaba casi vacío; bajo demanda se llena, y con él llega tanto el poder como la responsabilidad de tratar cada campo como una entrada no confiable.
Conviene distinguir dos familias dentro del contexto: la de lectura —request, params, url, clientAddress—, que describe lo que llega, y la de acción —redirect, rewrite, cookies—, con la que respondes o mutas estado. Observar es inocuo; efectuar tiene consecuencias, así que las segundas piden más cuidado. Ver el APIContext con ese mapa —qué observa y qué efectúa— ordena mentalmente cualquier handler que escribas.