Params dinámicos y getStaticPaths en endpoints
El enrutado dinámico que aprendiste para páginas se transfiere intacto a los endpoints: un fichero [id].ts captura un segmento variable de la URL y lo entrega en params. En bajo demanda ese valor llega en cada petición y solo hay que resolverlo contra los datos; en estático hace falta getStaticPaths para enumerar qué endpoints hornear, exactamente igual que con las páginas. Cómo combinar corchetes y doble extensión en [id].json.ts, cómo pasar props desde getStaticPaths y cómo emitir un 404 honesto cuando el recurso no existe.
Un endpoint fijo sirve un recurso único. Pero la mayoría de las APIs hablan de colecciones: un JSON por producto, un .txt por usuario, un feed por categoría. Para eso necesitas rutas dinámicas, y aquí llega una de las mejores noticias de Astro: el mecanismo que ya dominas para páginas —los corchetes en el nombre, params, getStaticPaths— funciona idéntico en los endpoints. Un fichero [id].json.ts captura un segmento variable de la URL y te lo entrega en params.id. Lo único que cambia respecto a una página es el codominio: donde antes salía HTML, ahora sale JSON, texto o los bytes que decidas.
- Capturar un segmento variable con
[id].tsy leerlo desdeparams. - Resolver
paramsen vivo en un endpoint bajo demanda, singetStaticPaths. - Enumerar endpoints estáticos con
getStaticPaths, pasandoparamsyprops. - Combinar corchetes y doble extensión en
[id].json.tsy emitir un404honesto.
El corchete captura, params entrega
Un par de corchetes en el nombre de un fichero declara un segmento dinámico: la parte de la ruta que varía. src/pages/api/[id].json.ts responde a /api/1.json, /api/2.json y cualquier otro valor, y el segmento capturado llega en context.params.id. Es exactamente la misma convención que [slug].astro, solo que el fichero es un endpoint. En bajo demanda, resolver ese parámetro es leerlo y consultarlo contra tus datos:
// src/pages/api/[id].json.ts -> /api/:id.json
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = async ({ params }) => {
const producto = await db.buscar(params.id);
if (!producto) {
return Response.json({ error: 'no encontrado' }, { status: 404 });
}
return Response.json(producto);
};
La doble extensión y los corchetes conviven sin fricción: [id] es el segmento variable, .json es el formato que anuncia la URL, .ts es la señal para el compilador. La ruta resultante, /api/:id.json, une las tres piezas. Y como el endpoint acepta cualquier id, incluido uno inventado, la guarda del recurso ausente no es opcional: comprobar y devolver 404 es lo que separa una API honesta de una que revienta con un 500 ante la primera URL equivocada.
Conviene subrayar por qué esa guarda pesa tanto aquí. Un [id].json.ts bajo demanda es una promesa de que toda URL con esa forma tiene una respuesta, y quien cumple la promesa eres tú, no el enrutador. Si consultas sin comprobar, el primer id inexistente propaga un valor nulo hasta que algo estalla, y el cliente recibe un 500 genérico donde merecía un 404 claro. El 404 es una respuesta legítima —esto no existe—, no un fallo del sistema; confundirlo con un 500 —me he roto— es mentir sobre lo que pasó y, de paso, penalizar en los buscadores, que sí distinguen una dirección vacía de un servidor averiado.
Un valor capturado de la URL es siempre una cadena: params.id vale "42", no 42. Si tu clave es numérica o tu consulta espera otro tipo, conviértelo tú —Number(params.id)— y valida el resultado antes de usarlo. Un Number('abc') da NaN, y una consulta con NaN es un fallo silencioso esperando a ocurrir. Tratar params como entrada no confiable, igual que el cuerpo de la petición, es la actitud correcta.
Estático: getStaticPaths enumera qué hornear
En un proyecto sin servidor, un fichero con corchetes plantea un problema: ¿qué id existen? Nadie puede resolverlos al vuelo porque no hay nadie ejecutando nada en producción. La respuesta es la misma que para las páginas dinámicas: getStaticPaths, la función que enumera en el build el conjunto de valores a generar. Astro la llama una vez, recorre lo que devuelve y hornea un fichero por entrada.
// src/pages/api/[id].json.ts
import type { APIRoute, GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
};
export const GET: APIRoute = ({ props }) => {
return Response.json(props.post.data);
};
El paralelismo con las páginas es total. Cada objeto del array lleva dos claves: params, que fija el valor del segmento —y por tanto el nombre del fichero horneado—, y props, que inyecta datos ya resueltos en el handler. Ese segundo campo es un regalo de rendimiento: como getStaticPaths ya cargó la colección para enumerar, aprovecha para pasar cada entrada por props y evítate volver a consultarla dentro del GET. En estático no hay 404 que emitir: solo existen los ficheros que enumeraste, y cualquier otra URL es un 404 automático del servidor de ficheros.
getStaticPaths ya recorrió la colección para poder enumerar las rutas. Devolver cada entrada por props aprovecha ese trabajo: el GET recibe el dato cargado y no vuelve a tocar la fuente. Sin props caerías en el patrón ingenuo —enumerar en getStaticPaths y volver a buscar dentro del handler por params.id—, dos lecturas donde basta una, multiplicadas por cada entrada de la colección. En un conjunto grande, eso son minutos de build regalados. props no es comodidad: es el canal por el que el enumerador le entrega al generador lo que ya sabe.
El molde admite cualquier formato por entrada, no solo JSON. [id].csv.ts con getStaticPaths hornea un CSV por registro; [slug].txt.ts, un texto por página. Cambias la extensión del nombre y el Content-Type del Response, y el mismo enumerador te sirve una familia entera de artefactos, uno por cada entrada de la colección.
Esa combinación de comodines y formato convierte los endpoints en una fábrica de datos: una sola convención de nombres —corchetes para el segmento, extensión para el tipo— cubre desde un JSON por producto hasta un .txt por página o un feed por categoría, todo enumerado por la misma función y horneado en el mismo build.
El mismo dial: build o petición
Compara los dos handlers que ya has visto. El estático recibe su dato por props —precalculado en el build— y nunca falla, porque solo se generó para valores válidos. El de bajo demanda lee params en vivo, consulta al momento y debe cargar él mismo con el caso del recurso ausente. El cuerpo del GET apenas cambia; lo que cambia es de dónde viene su dato y quién responde por lo que no existe.
Esa casi identidad es una guía de diseño. Escribe el handler pensando primero en el parámetro y el dato, y deja para el final la decisión de cuándo resolverlos. Un GET que lee su entrada de un objeto —sea props en estático o el fruto de una consulta en bajo demanda— y proyecta la salida sin presuponer el momento de ejecución se mueve de un modo al otro cambiando poco más que la línea de prerender y añadiendo la guarda del 404. Cuanto más neutral sea tu código respecto al cuándo, más barato te resultará mover una ruta el día que el volumen o la frescura del contenido lo exijan.
Estatico
getStaticPaths enumera en el build. Un fichero por entrada, props precalculadas, cero fallos posibles.
Bajo demanda
Sin enumerar; params se lee en cada peticion. Universo abierto, dato fresco, guarda del 404 obligatoria.
Corchetes mas formato
[id].json.ts une segmento variable y tipo de salida en una sola ruta: /api/:id.json.
Catch-all
[...ruta].ts captura varios segmentos en params.ruta, util para proxys y arboles de rutas.
Los corchetes también capturan varios segmentos a la vez. src/pages/api/[...ruta].ts responde a /api/a, /api/a/b, /api/a/b/c, y entrega el resto de la ruta en params.ruta como una cadena con barras. Ese patrón catch-all es la base de un proxy —reenviar toda una subruta a otro servicio— y de cualquier endpoint que deba manejar una jerarquía de profundidad desconocida. En estático, ese mismo comodín se enumera con getStaticPaths devolviendo rutas con barras en el valor.
Un esquema mínimo del comodín en bajo demanda:
// src/pages/api/[...ruta].ts
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = ({ params }) => {
const segmentos = (params.ruta ?? '').split('/').filter(Boolean);
return Response.json({ profundidad: segmentos.length, segmentos });
};
params.ruta llega como una sola cadena con barras —a/b/c—, no como un array. Partirla con split('/') y descartar los vacíos te da los segmentos individuales para decidir nivel a nivel. Y como con cualquier params, trátala como entrada no confiable: normaliza y valida antes de reenviarla, no sea que un .. o una barra de más te lleve a un recurso que no debías exponer.
Y la elección de modo no es global ni definitiva: prerender se decide ruta por ruta. Un mismo proyecto puede hornear su catálogo estable como [id].json.ts estático y, en el fichero de al lado, resolver su buscador bajo demanda, sin que uno contamine al otro. El enrutado es el mismo; el dial del cuándo lo giras endpoint por endpoint, según lo que cada recurso pida.
Piensa en getStaticPaths como el enumerador y en el handler como el generador: el primero declara qué existe, el segundo dice cómo se ve cada cosa. Esa separación —qué frente a cómo— es la que te deja cambiar el formato de salida sin tocar la lista de rutas, y cambiar la lista sin tocar el formato. Dos ejes que el enrutado por ficheros mantiene deliberadamente desacoplados.
flowchart TD
F[archivo id json ts] --> MODO{modo de la ruta}
MODO -->|estatico| GSP[getStaticPaths enumera en el build]
GSP --> HORNEA[un fichero json por entrada]
MODO -->|bajo demanda| LEE[lee params id por peticion]
LEE --> Q[consulta la fuente en vivo]
Q --> EX{existe}
EX -->|si| OK[Response json]
EX -->|no| NF[Response 404]
style F fill:#89b4fa,color:#11111b
style HORNEA fill:#a6e3a1,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style NF fill:#f38ba8,color:#11111bLo más profundo de este nivel es lo poco que hay de nuevo en él. Aprendiste [param], params y getStaticPaths para páginas, y resulta que ese conocimiento se transfiere sin una sola grieta a los endpoints. No es casualidad ni comodidad: es que el enrutado dinámico de Astro es una abstracción sobre la relación entre una forma de URL y una función, completamente indiferente a lo que esa función devuelva. El corchete captura un segmento, getStaticPaths enumera el dominio de valores, params entrega el argumento; que la función termine emitiendo un <html> o un Response.json es una decisión que ocurre al final, en el tipo de la salida, no en el mecanismo de enrutado. Esa indiferencia es una señal de buen diseño: la parte difícil —mapear infinitas URLs posibles a funciones, decidir cuáles precalcular— se resolvió una vez y sirve para todo. Cuando lo ves así, dejas de tener dos temas en la cabeza —“rutas de páginas” y “rutas de API”— y tienes uno solo: rutas, funciones de un patrón de URL a una respuesta, que unas veces pintas como página y otras proyectas como dato. Y el eje estático contra bajo demanda deja de ser una propiedad de “las APIs” o de “las páginas” para revelarse como lo que es: una elección de cuándo evaluar la función de enrutado —toda de golpe en el build, o una a una por petición— que arrastra el rendimiento, las garantías y quién responde por lo que no existe. Un dial, dos posiciones, la misma máquina debajo.
- Crea
src/pages/api/[id].json.tsen modo estático congetStaticPathsque enumere una colección y sirva un JSON por entrada víaprops. - Ejecuta
astro buildy confirma que se ha horneado un fichero.jsonpor cada entrada dentro dedist. - Crea una variante bajo demanda con
export const prerender = falseque leaparams.iden vivo, consulte la fuente y devuelva404si no existe. - Añade
src/pages/api/[...ruta].tsque capture varios segmentos, registraparams.rutay razona qué caso de uso —proxy, árbol— justifica el comodín.