Endpoints estáticos: un GET que hornea ficheros
La otra cara del enrutado por ficheros: un módulo .ts bajo src/pages que exporta GET y devuelve un Response no produce HTML, produce bytes arbitrarios. En modo estático ese GET se ejecuta una sola vez en el build y su salida se congela a un fichero físico en dist. Cómo la doble extensión del nombre construye la URL y el formato, cómo fijar Content-Type y estado a mano, y por qué un endpoint estático es, en el fondo, un generador de artefactos derivado de tu contenido.
Un fichero que no es una página
El enrutado de Astro clasifica lo que encuentra en src/pages por su extensión. Un .astro, .md o .mdx es una página: su salida es HTML y su extensión se descarta al formar la URL. Un .ts o .js es un endpoint: no se le presupone HTML, y lo que devuelva su handler se sirve tal cual. La diferencia no está en el enrutado —ambos comparten el mismo árbol de ficheros—, sino en el contrato de salida. Una página promete una vista; un endpoint promete una Response.
El handler más simple exporta una constante GET tipada como APIRoute y devuelve un Response de la plataforma web:
// src/pages/salud.txt.ts -> /salud.txtimport type { APIRoute } from 'astro';export const GET: APIRoute = () => { return new Response('ok\n', { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, });};Detente en el nombre del fichero: salud.txt.ts lleva dos extensiones. La última, .ts, es para el compilador —le dice a Astro que esto es un módulo que hay que ejecutar, no un fichero estático que copiar—. La penúltima, .txt, sí forma parte de la URL, porque en un endpoint la extensión no se descarta. El resultado se sirve en /salud.txt. Ese patrón —.formato.ts— es la convención con la que declaras a la vez la ruta y el tipo de lo que emites.
Horneado en el build: el endpoint como generador
Aquí está la corrección mental que separa entender los endpoints de recitar su API. En un proyecto estático no hay servidor en producción. El GET no se ejecuta cuando un visitante pide la URL; se ejecuta una sola vez, durante el build, y el cuerpo del Response que devuelve se escribe a un fichero en dist, en la ruta que dicta el nombre. Lo que se despliega no es tu función: es su resultado, un fichero plano que cualquier CDN sirve sin ejecutar nada.
Eso convierte el endpoint en algo más honesto de lo que parece: una función pura de las entradas del build —tus imports, tu configuración, tu contenido— hacia un artefacto en disco.
// src/pages/api/version.json.ts -> /api/version.jsonimport type { APIRoute } from 'astro';import { version } from '../../../package.json';export const GET: APIRoute = () => { const cuerpo = { name: 'nvim-dios', version, builtAt: new Date().toISOString(), }; return Response.json(cuerpo);};Response.json es azúcar de la plataforma: serializa el objeto y fija Content-Type: application/json por ti. Y fíjate en builtAt: no es la hora de la visita —eso no existe aquí—, sino la hora del build, congelada en el fichero para siempre hasta el siguiente despliegue. Quien venga de un servidor vivo tiende a leer esa línea como dinámica; en estático es una constante calculada una vez. Interiorizar ese cuándo es la mitad del nivel.
Esa naturaleza tiene una consecuencia práctica valiosa: el endpoint es determinista. Dos builds con las mismas entradas producen exactamente el mismo fichero, byte a byte, porque nada de fuera —ni la hora real, ni un visitante, ni el azar de la red— entra en el cálculo salvo que tú lo introduzcas. Es reproducible y auditable. Cuando quieras que la salida cambie, cambias la entrada: un campo del frontmatter, una variable de entorno leída con import.meta.env, una entrada de colección. La salida es una función de esas entradas, y solo de esas.
Para qué sirve: artefactos derivados de tu contenido
Construir el Response a mano —en vez de con el atajo Response.json— te devuelve control total sobre las tres palancas de una respuesta HTTP: el cuerpo, las cabeceras y el estado. Un manifiesto de PWA, un robots.txt, un llms.txt, un índice de búsqueda que tu cliente descargará, un volcado de datos para otra herramienta: todos son ficheros que puedes derivar de tu contenido en lugar de escribir a mano y mantener sincronizados.
El caso más potente es alimentar el artefacto desde tu contenido. Un índice de búsqueda —un JSON que el cliente descarga una vez y consulta sin servidor— se hornea leyendo la colección en el build:
// src/pages/search-index.json.tsimport type { APIRoute } from 'astro';import { getCollection } from 'astro:content';export const GET: APIRoute = async () => { const posts = await getCollection('blog', ({ data }) => !data.draft); const indice = posts.map((p) => ({ id: p.id, title: p.data.title, tags: p.data.tags ?? [], })); return Response.json(indice);};La misma getCollection que pinta tus páginas alimenta ahora un fichero de datos, y el map decide qué campos son públicos. No se duplica nada: hay una fuente y varias proyecciones, cada una horneada a su fichero.
// src/pages/manifest.webmanifest.tsimport type { APIRoute } from 'astro';export const GET: APIRoute = () => { const manifest = { name: 'nvim dios', start_url: '/', display: 'standalone', theme_color: '#11111b', }; return new Response(JSON.stringify(manifest), { status: 200, headers: { 'Content-Type': 'application/manifest+json' }, });};El mismo molde emite cualquier tipo MIME: cambias el Content-Type y el cuerpo. Y como la salida es un fichero, hereda todas las virtudes de lo estático: cero cómputo por visita, cacheable de forma agresiva, servible desde el borde. No has montado un backend; has añadido un paso de compilación que produce un fichero más. La contrapartida es la rigidez propia de lo horneado: la salida solo cambia cuando reconstruyes, así que un endpoint estático sirve para lo estable entre despliegues —un manifiesto, un índice, un feed— y no para lo que depende de cada visitante o del instante. El día que el dato deje de caber en el build, la misma función se moverá a bajo demanda sin apenas reescribirse, y ese es justo el puente hacia el resto del nivel.