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.
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.jsonimport 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.
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.tsimport 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.
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.
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].tsimport 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 });};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.