wandres.dev
ENDPOINTS Y API · rutas de servidor

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.

⏱ 14 min

Hasta ahora una ruta era una página: un .astro que emite HTML. Pero el enrutado por ficheros de Astro esconde una simetría más amplia. Un módulo .ts o .js colocado bajo src/pages que exporta una función GET y devuelve un objeto Response no genera una página: genera bytes, los que tú decidas, con el tipo que tú decidas. Y en un proyecto estático ese GET no atiende peticiones —no hay servidor vivo—, sino que se ejecuta una sola vez durante el build y su salida se hornea a un fichero en dist. Un endpoint estático no es una API: es un generador de artefactos, una forma de calcular ficheros a partir de tu contenido y escribirlos en disco.

🎯 Al terminar esta lección sabrás
  • Reconocer un endpoint: un .ts bajo src/pages que exporta GET y devuelve Response.
  • Entender que en estático el GET se ejecuta en el build y su salida se congela a un fichero.
  • Derivar la URL y el formato de la doble extensión del nombre (.json.ts, .txt.ts).
  • Fijar Content-Type, cuerpo y estado construyendo el Response a mano.

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.txt
import 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, 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.json
import 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.

ℹ️
Qué contexto tiene sentido en un endpoint estático

El GET recibe siempre un APIContext, pero en estático buena parte de él es inerte. No hay una petición real, así que leer request.headers, clientAddress o una query dinámica es un error de categoría: no hay visitante del que provengan. Lo que tienes es todo lo disponible en el build: import de datos y JSON, getCollection, Astro.site, variables de entorno. Un endpoint estático se alimenta de lo que sabes al compilar, nunca de lo que ocurre al visitar. Si necesitas lo segundo, la ruta debe pasar a bajo demanda —el tema de la próxima lección—.

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.ts
import 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.ts
import 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' },
  });
};
💡
La primera extensión es la URL; la segunda, la señal

La convención .formato.ts separa dos preguntas que suelen confundirse. Qué URL ocupa el fichero lo decide la primera extensión —posts.json.ts vive en /posts.json, feed.xml.ts en /feed.xml—. Qué es el fichero para el compilador lo decide la segunda, siempre .ts o .js. Elegir bien la primera extensión es diseñar la dirección pública de tu artefacto; y el Content-Type que fijas dentro debe concordar con ella, para que cliente y servidor cuenten la misma historia sobre esos bytes.

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.

🛰️

Datos JSON

Un manifiesto, un indice de busqueda o un volcado de tu contenido, serializado con Response.json.

📄

Texto plano

Un robots.txt, un llms.txt o un humans.txt derivado de tu config, con tipo text/plain.

🧩

Cualquier MIME

Un .webmanifest, un .ics de calendario o un OPML: cambia la cabecera y el cuerpo.

flowchart LR
SRC[archivo ts en src pages] --> BUILD[el build ejecuta GET]
BUILD --> RES[Response con bytes]
RES --> FILE[fichero fisico en dist]
FILE --> CDN[el CDN lo sirve tal cual]
style SRC fill:#89b4fa,color:#11111b
style RES fill:#f9e2af,color:#11111b
style FILE fill:#a6e3a1,color:#11111b
style CDN fill:#a6e3a1,color:#11111b
Una URL es una función pura evaluada en el build

Lo que un endpoint estático revela es que la web, bajo la piel, es un sistema de ficheros proyectado sobre HTTP, y que una URL no es más que un nombre para unos bytes. Durante años esa proyección la escribíamos a mano: subíamos ficheros a un servidor y ahí estaban. Los frameworks introdujeron una capa de cálculo —las páginas se generan— pero mantuvieron la ficción de que solo se generaba HTML. El endpoint estático disuelve esa ficción y la lleva a su conclusión: si una página es una función del build a un fichero HTML, entonces un endpoint es esa misma función a un fichero de cualquier tipo, y la distinción entre página, dato y artefacto se evapora. Lo que queda es una idea limpia: tu sitio es un conjunto de funciones puras que, evaluadas en el momento de compilar, producen el árbol de ficheros que se despliega. Tu contenido es la entrada; las páginas, los feeds, los manifiestos y los índices son proyecciones distintas de esa misma entrada, calculadas de una vez y congeladas. Programar un endpoint estático es, literalmente, metaprogramación en tiempo de compilación: escribes código cuyo producto no es comportamiento en ejecución, sino un fichero. Y el día que ves dist no como una caja negra que Astro llena, sino como el codominio de un conjunto de funciones que tú controlas byte a byte, dejas de construir sitios y empiezas a construir la maquinaria que los emite.

⚔️ Hornea un fichero desde tu contenido
  1. Crea src/pages/salud.txt.ts que exporte GET y devuelva un Response de texto plano con la cabecera Content-Type correcta; visítalo en /salud.txt.
  2. Crea src/pages/api/version.json.ts que sirva un objeto con version importado de package.json y un builtAt con new Date().toISOString().
  3. Ejecuta astro build y localiza los ficheros generados dentro de dist; recarga la página dos veces y razona por qué builtAt no cambia.
  4. Añade una tercera ruta que emita otro tipo MIME —un .webmanifest— construyendo el Response a mano con status, headers y cuerpo.