wandres.dev
ENDPOINTS Y API · rutas de servidor

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.

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • Convertir un endpoint en bajo demanda con export const prerender = false y un adaptador.
  • Exportar un handler por verbo: GET, POST, PUT, PATCH, DELETE.
  • Leer el cuerpo de la petición con request.json y request.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.

💡
El nombre del recurso vive en la ruta, el verbo en el método

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 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.

⚠️
Valida lo que entra: el cuerpo es territorio hostil

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 lecturarequest, params, url, clientAddress—, que describe lo que llega, y la de acciónredirect, 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:#11111b
Un endpoint es una función de Request a Response, y eso lo es todo

Cuando 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.

⚔️ Modela un recurso con varios verbos
  1. Crea src/pages/api/notas.ts con export const prerender = false y un GET que devuelva una lista con Response.json.
  2. Añade un POST que lea await request.json(), valide que trae los campos esperados y responda 201; devuelve 400 si el cuerpo es inválido.
  3. Añade un DELETE que lea el id de url.searchParams y responda 204 con cuerpo nulo; comprueba que un verbo sin handler devuelve 405.
  4. Desestructura y registra clientAddress, url.pathname y request.headers en un handler, y observa cómo el APIContext refleja cada petición concreta.