wandres.dev
RSS, SITEMAP Y DATOS · feeds y datos derivados

Endpoints que emiten JSON y otros formatos

Servir datos derivados del contenido en formatos que no son HTML: exportar GET en un .ts, devolver un objeto Response con el Content-Type correcto, construir el cuerpo desde getCollection eligiendo qué campos exponer, generar un endpoint por entrada con getStaticPaths, y emitir JSON, texto plano o CSV desde la misma colección.

⏱ 15 min

Un feed y un sitemap son casos concretos de una idea general: una URL puede devolver cualquier cosa, no solo HTML. Cuando exportas una función GET en un fichero .ts bajo src/pages y devuelves un Response, has construido una API sin backend separado: un endpoint que sirve JSON, texto o CSV derivado de tu contenido. La misma getCollection que pinta las páginas alimenta ahora a consumidores que no son navegadores —un script, otra app, una hoja de cálculo—. Tu sitio deja de ser solo algo que se mira y pasa a ser algo que se consulta.

🎯 Al terminar esta lección sabrás
  • Exportar GET en un .ts y devolver un Response con la cabecera correcta.
  • Construir el cuerpo del endpoint desde getCollection.
  • Servir JSON, texto plano u otros formatos derivando el nombre del fichero.
  • Generar un endpoint por entrada con getStaticPaths.

Un GET que devuelve JSON

Un endpoint de datos es un fichero cuyo nombre incrusta el formato de salida. src/pages/api/posts.json.ts se sirve en /api/posts.json: la primera extensión forma parte de la URL, la segunda solo le dice a Astro que es un endpoint. Dentro exportas una constante GET tipada como APIRoute y devuelves un Response. Para JSON, el método estático Response.json de la plataforma web fija la cabecera Content-Type por ti.

// src/pages/api/posts.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 datos = posts.map((post) => ({
    id: post.id,
    title: post.data.title,
    date: post.data.pubDate,
  }));
  return Response.json(datos);
};

Fíjate en el map: el endpoint no vuelca la entrada entera, sino que elige qué campos expone. Eso importa, porque un endpoint público es un contrato con quien lo consuma y una superficie por la que puede filtrarse información. Decidir la forma de la salida —qué campos, con qué nombres— es diseñar esa API, no un detalle accesorio.

Derivar el contenido: elegir la forma pública

El endpoint es una vista de la colección, igual que una página, pero cuya plantilla decides byte a byte. Filtras borradores, seleccionas campos, renombras claves y ordenas para el consumidor. Como es estático por defecto, la respuesta se hornea a un fichero en el build; si lo sirves bajo demanda, una cabecera Cache-Control gobierna cuánto puede guardarse en caché. Construir el Response a mano te da control total sobre el estado y las cabeceras.

export const GET: APIRoute = async () => {
  const posts = await getCollection('blog');
  const datos = posts.map((post) => ({ id: post.id, title: post.data.title }));
  return new Response(JSON.stringify(datos), {
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'public, max-age=3600',
    },
  });
};

Construir el Response a mano también te da el código de estado, que es vocabulario para el consumidor automático. Un endpoint que no encuentra lo pedido devuelve un 404 con un cuerpo JSON que lo explica; uno que recibe una petición mal formada, un 400. Esos códigos los entiende un programa sin leer el cuerpo, y omitirlos —responder siempre 200 con el error escondido dentro— es una de las formas más comunes de API maleducada, la que obliga al cliente a adivinar si algo salió bien.

ℹ️
Un mismo contenido, tres consumidores

Detente en lo que ha pasado: la colección blog alimenta ya las páginas HTML, el feed RSS y este endpoint JSON. Tres consumidores —humanos con navegador, lectores de feeds, programas— leyendo la misma fuente por tres proyecciones distintas. Ninguno posee el contenido; los tres lo derivan. El endpoint es la proyección para máquinas, y su map es donde decides qué parte de tus datos merece ser pública y con qué forma.

Un endpoint por entrada con getStaticPaths

Como las páginas, un endpoint puede ser dinámico. src/pages/api/[id].json.ts sirve un JSON por entrada, y en modo estático necesita getStaticPaths para enumerar qué ficheros generar —exactamente el mismo patrón que una ruta [slug].astro, solo que la salida es JSON en vez de HTML—. En SSR, en cambio, el endpoint lee params en cada petición y no necesita enumerar nada por adelantado.

// 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: getStaticPaths recorre la colección y emite una ruta por entrada, y cada ruta —sea página o endpoint— recibe su entrada como prop. Cambia el tipo de salida, no el mecanismo de enrutado. Aprendido para páginas, se reutiliza tal cual para APIs.

En SSR ese mismo endpoint dinámico no necesita getStaticPaths: lee params.id de la petición y consulta la entrada en el momento, igual que una página bajo demanda. Y si un recurso debe responder a varios verbos con la misma lógica, ALL los atiende todos con una sola función, cómodo cuando lo que importa es la ruta y no el método concreto que la invoca.

Otros formatos

El mismo molde sirve cualquier tipo MIME: cambias la cabecera Content-Type y el cuerpo. Un CSV para exportar a una hoja de cálculo, texto plano, un .ics de calendario, incluso una imagen generada. La extensión del nombre construye la URL, y la cabecera le dice al cliente cómo interpretar los bytes.

// src/pages/posts.csv.ts  ->  /posts.csv
export const GET: APIRoute = async () => {
  const posts = await getCollection('blog');
  const filas = posts.map((p) => `${p.id},${p.data.title}`).join('\n');
  return new Response(`id,title\n${filas}`, {
    headers: { 'Content-Type': 'text/csv; charset=utf-8' },
  });
};
🧾

JSON

El formato de datos por defecto. Response.json fija la cabecera; ideal para consumir desde otras apps.

📊

CSV

Filas de texto separadas por comas para hojas de cálculo. Tipo text/csv y cuerpo construido a mano.

📄

Texto

Texto plano para un robots.txt, un manifiesto o una respuesta simple. Tipo text/plain.

flowchart LR
GC[getCollection blog] --> SHAPE[elegir campos y forma]
SHAPE --> RES[Response con Content Type]
RES --> J[salida JSON]
RES --> C[salida CSV]
RES --> T[salida texto]
style GC fill:#89b4fa,color:#11111b
style J fill:#a6e3a1,color:#11111b
style C fill:#a6e3a1,color:#11111b
style T fill:#a6e3a1,color:#11111b
Ya tienes un CMS headless cuya cabeza eliges en cada URL

Un endpoint de datos disuelve una frontera que la mayoría de los desarrolladores da por natural: la que separa el sitio de la API, el frontend del backend, lo que se mira de lo que se consulta. Esa separación nunca fue una ley de la naturaleza, sino un accidente de cómo crecieron las herramientas —un framework para las vistas, otro sistema para los datos, cada uno con su servidor, su lenguaje y su despliegue—. Cuando entiendes que una página y un endpoint son la misma cosa, un contexto de petición que devuelve un Response, y que tu contenido es una colección consultable, la frontera se evapora. Lo que tienes entre manos es un CMS headless cuya cabeza no está fijada: para el navegador, la proyectas como HTML; para un programa, como JSON; para una hoja de cálculo, como CSV; para un lector, como XML. Todas esas salidas son un map sobre la misma getCollection, y todas viven en el mismo proyecto, sin un servicio aparte que sincronizar. El estándar que lo hace posible es el objeto Response de la plataforma web, el mismo que usan los Service Workers, Deno y los runtimes del edge, lo que significa que lo que aprendes aquí es transferible mucho más allá de Astro. La consecuencia para cómo diseñas es honda: dejas de pensar en construir un sitio y luego, aparte, una API, y empiezas a pensar en una única fuente de datos de la que emanan cuantas representaciones necesiten sus consumidores. El contenido es el sustantivo; las páginas, los feeds, los endpoints y las exportaciones son verbos que lo proyectan. Y decidir qué proyección merece cada consumidor, con qué campos y qué forma, es el verdadero trabajo de diseño que este nivel te enseña a ver.

⚔️ Publica tu contenido como API
  1. Crea src/pages/api/posts.json.ts desde getCollection('blog'), exponiendo solo id, title y date.
  2. Añade una cabecera Cache-Control y verifica el Content-Type en las herramientas del navegador.
  3. Crea un endpoint dinámico src/pages/api/[id].json.ts con getStaticPaths que sirva un JSON por entrada.
  4. Añade src/pages/posts.csv.ts y razona por qué la misma colección puede alimentar HTML, JSON y CSV sin duplicar ningún dato.