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.
Un endpoint estático es un generador: se ejecuta en el build y congela un fichero. Un endpoint bajo demanda es otra cosa por completo: se ejecuta en un servidor vivo, una vez por cada petición que llega, con acceso a lo que esa petición trae consigo. El fichero deja de ser una plantilla precocinada y vuelve a ser lo que su forma siempre sugirió —una función de una petición a una respuesta—. Y como ahora hay peticiones reales, el endpoint puede distinguir qué se le pide: no solo leer con GET, sino crear con POST, reemplazar con PUT, parchear con PATCH o borrar con DELETE. Exportas una función por verbo, lees el cuerpo entrante y decides la respuesta.
- Convertir un endpoint en bajo demanda con
export const prerender = falsey un adaptador. - Exportar un handler por verbo:
GET,POST,PUT,PATCH,DELETE. - Leer el cuerpo de la petición con
request.jsonyrequest.formData. - Reconocer los campos del
APIContext:request,params,url,cookies,clientAddress.
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.ts
import type { APIRoute } from 'astro';
export const prerender = false; // este endpoint se ejecuta por peticion
export 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.ts
import 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.
Un error común al llegar de otros marcos es codificar la acción en la URL —/crearNota, /borrarNota—. La disciplina REST separa las dos preguntas: qué recurso, en la ruta —/api/notas, /api/notas/42—; qué haces con él, en el método —GET, POST, DELETE—. Un mismo fichero de endpoint modela así un recurso entero, y sus handlers son las operaciones legítimas sobre él. La ruta es el sustantivo; el verbo, el verbo.
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.
await request.json() puede fallar si el cliente manda basura, y aunque no falle, el objeto resultante es de tipo any: nada garantiza que tenga la forma que esperas. Un endpoint bajo demanda es una superficie pública que cualquiera puede golpear con lo que quiera. Envuelve la lectura en un try/catch, valida la estructura —Zod es el compañero natural— y responde 400 Bad Request ante lo malformado antes de tocar tu base de datos. Confiar en el cuerpo entrante es la vía más corta a un 500, o a algo peor.
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:
request
El Request estandar: metodo, cabeceras y cuerpo. La fuente de todo lo que el cliente envia.
params y url
params trae los segmentos dinamicos de la ruta; url es un URL con pathname y searchParams.
cookies
Un AstroCookies para leer, fijar y borrar cookies con get, set y delete.
clientAddress
La IP del cliente, disponible solo bajo demanda: la clave para un rate-limit por origen.
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.
flowchart TD
REQ[peticion entrante] --> M{que metodo}
M -->|GET| G[handler GET lee url y params]
M -->|POST| P[handler POST lee request json]
M -->|DELETE| D[handler DELETE]
M -->|sin handler| E[405 method not allowed]
G --> RES[Response]
P --> RES
D --> RES
style REQ fill:#89b4fa,color:#11111b
style RES fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111bCuando despojas un endpoint bajo demanda de la jerga del framework, lo que queda es la firma más antigua y más honesta de la programación de servidores: una función que toma una petición y devuelve una respuesta. Request entra, Response sale; entre medias, tu lógica. Todo lo demás —los verbos, las cabeceras, los códigos de estado, las cookies— son estructura dentro de esos dos objetos, no invenciones de Astro. Por eso lo que escribes aquí no es conocimiento cautivo: la misma función, con cambios cosméticos, corre en un Worker de Cloudflare, en un handler de Deno, en un Service Worker del navegador. Astro no inventó un modelo de servidor; adoptó el estándar de la plataforma web y te lo puso bajo el enrutado por ficheros. La consecuencia conceptual es liberadora: dejas de aprender “cómo se hacen APIs en Astro” y empiezas a aprender el modelo de petición y respuesta de la web, del que Astro es solo una encarnación. Y el salto de estático a bajo demanda, visto así, no es cambiar de herramienta sino cambiar de tiempo de evaluación: la misma función que en el build se aplicaba una vez a entradas conocidas ahora se aplica en vivo a la entrada exacta de cada visitante. El código apenas cambia; lo que cambia es cuándo corre y de quién viene su argumento. Interiorizar que un endpoint es solo (request) => Response es dejar de temer al backend: no hay un servidor misterioso ahí, hay una función, y tú la escribes.
- Crea
src/pages/api/notas.tsconexport const prerender = falsey unGETque devuelva una lista conResponse.json. - Añade un
POSTque leaawait request.json(), valide que trae los campos esperados y responda201; devuelve400si el cuerpo es inválido. - Añade un
DELETEque lea eliddeurl.searchParamsy responda204con cuerpo nulo; comprueba que un verbo sin handler devuelve405. - Desestructura y registra
clientAddress,url.pathnameyrequest.headersen un handler, y observa cómo elAPIContextrefleja cada petición concreta.