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.
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.tsimport 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 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.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: 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.csvexport 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' }, });};