Next.js

Content negotiation para agentes de IA: servir markdown en vez de HTML

Autorangel cruz
Actualizado
Publicado
Lectura17 min de lectura
Content negotiation para agentes de IA: servir markdown en vez de HTML

Cloudflare anunció una función que reduce el uso de tokens en agentes de IA en un 80%. Hay un enfoque que logra 97% de reducción y te da control total sobre la implementación. Este sitio ya lo está usando en producción.

Los agentes de IA como Claude Code, OpenCode y otros ahora envían el header Accept: text/markdown cuando solicitan contenido web, porque las páginas HTML completas desperdician entre 80% y 99% de los tokens en navegación, estilos, scripts y anuncios. Para un LLM, todo ese markup es ruido.

En este artículo comparo dos enfoques: conversión en el edge (como Cloudflare) y conversión desde la fuente, la que uso aquí y la que me parece superior. Incluyo el código de producción para Next.js y benchmarks de este sitio.

El problema: HTML no es óptimo para agentes de IA

Imagina que un agente de IA quiere leer un artículo técnico en tu blog. Hace una petición a tu URL y recibe... 316KB de HTML.

Un ejemplo real de este sitio:

# Respuesta HTML completa: 316,270 bytes
curl -sL https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista | wc -c
316270
 
# Respuesta markdown: 1,338 bytes
curl -sL https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista.md | wc -c
1338

Reducción: 99.6% en tamaño de payload

Esa medición es de febrero de 2026. El post creció desde entonces, y hoy los mismos dos comandos devuelven 446.238 y 9.093 bytes: una reducción del 98%.

Esto es lo que contiene esa respuesta HTML:

<!DOCTYPE html>
<html>
 
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width">
		<title>Adminer: gestor de bases de datos minimalista</title>
		<link rel="stylesheet" href="/styles.css">
		<script src="/analytics.js"></script>
		<!-- 50+ meta tags para SEO y Open Graph -->
	</head>
 
	<body>
		<nav class="sticky top-0 z-50 backdrop-blur-md">
			<!-- Navegación completa con menú, logo, búsqueda -->
		</nav>
 
		<main class="container mx-auto px-4">
			<article>
				<!-- AQUÍ ESTÁ EL CONTENIDO QUE EL AGENTE NECESITA -->
			</article>
 
			<aside>
				<!-- Sidebar con posts relacionados, categorías, etc. -->
			</aside>
		</main>
 
		<footer>
			<!-- Links, copyright, redes sociales -->
		</footer>
 
		<script src="/bundle.js"></script>
		<!-- Scripts de analytics, cookies, etc. -->
	</body>
 
</html>

De esos 316KB, el contenido real que el agente necesita es menos del 1%. El resto es:

  • Navegación y footer: 20-30KB de HTML que el agente ignora
  • CSS y clases de Tailwind: class="flex items-center justify-between px-4 py-2 text-sm font-medium..." aporta cero valor semántico
  • JavaScript bundles: Analytics, interacciones del cliente, frameworks
  • Meta tags: 50+ tags para SEO, Open Graph, Twitter Cards
  • Ads y cookie banners: Contenido comercial que distrae

Para un Large Language Model, esto es equivalente a:

Tokens estimados (HTML completo): ~80,000 tokens
Tokens estimados (markdown puro): ~350 tokens

Como las APIs de modelos cobran por token de entrada (ver la tabla de precios de Claude), el costo de cada lectura baja en la misma proporción que los tokens.

¿Por qué los agentes de IA luchan con HTML?

Los LLMs procesan contenido de forma distinta a los navegadores:

  1. No renderizan visualmente: Las clases CSS y estilos inline no aportan información útil
  2. No ejecutan JavaScript: Los scripts son texto incomprensible
  3. Necesitan estructura semántica: Markdown proporciona jerarquía clara (#, ##, listas, código)
  4. Ventana de contexto limitada: Cada token cuenta, por grande que sea la ventana del modelo
  5. Mejor comprensión con texto limpio: Sin distracciones, el modelo entiende mejor el contenido

Content negotiation: el estándar HTTP

La solución a este problema no es nueva. Se llama content negotiation (negociación de contenido) y es un estándar HTTP desde hace décadas.

¿Cómo funciona?

El cliente (agente de IA) envía un header Accept especificando qué tipo de contenido prefiere:

GET /post/mi-articulo HTTP/1.1
Host: www.angelcruz.dev
Accept: text/markdown

El servidor responde con el formato solicitado si está disponible:

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
Cache-Control: public, s-maxage=2592000
 
---
title: Mi Artículo
date: 2026-02-13
---
 
# Mi Artículo
 
Contenido del artículo en markdown...

Si el servidor no soporta markdown, responde con HTML y el header Content-Type: text/html:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: Accept
 
<!DOCTYPE html>
<html>...

El header Vary: Accept le dice a las CDNs y proxies que cacheen versiones separadas según el valor del header Accept.

¿Por qué es mejor que user-agent detection?

Algunos sitios intentan detectar agentes de IA mediante el User-Agent header:

// NO HAGAS ESTO
if (userAgent.includes('ClaudeCode') || userAgent.includes('GPTBot')) {
  return markdownResponse
}
return htmlResponse

Este enfoque tiene problemas:

  1. SEO risk: Google penaliza el "cloaking" (servir contenido diferente según user-agent)
  2. Frágil: Cada nuevo agente requiere actualizar la lista
  3. No estándar: Viola las mejores prácticas HTTP
  4. Falsos positivos: Un usuario real podría modificar su user-agent

Content negotiation es explícito y estándar: el cliente pide markdown con Accept, y el servidor negocia la mejor respuesta.

Agentes de IA que lo soportan

Según el anuncio de Cloudflare, estos agentes de código envían text/markdown en el header Accept:

  • Claude Code (Anthropic CLI)
  • OpenCode

Dos enfoques: edge vs source conversion

Hay dos estrategias para resolverlo, y funcionan de forma muy distinta.

Comparación: edge conversion vs source conversion

Aspecto Edge Conversion (Cloudflare) Source Conversion (Este Sitio)
Reducción de tokens ~80% ~97%
Fuente de conversión HTML → Markdown (parsing) Markdown original (directo)
Fidelidad del contenido Puede perder componentes custom 100% preservación
Metadatos disponibles Limitados (extraídos de HTML) Frontmatter completo
Implementación Toggle en dashboard Cloudflare Route handlers personalizados
Control sobre serialización Limitado (lógica edge) Total (código propio)
Costo Requiere plan Pro, Business o Enterprise de Cloudflare Gratis (Next.js built-in)
Latencia +20-50ms (parsing HTML) 0ms adicional (lectura directa)
Componentes custom Pueden perderse (<CodePlayground>) Manejo explícito
Dependencias externas Requiere Cloudflare Ninguna

Edge conversion: el enfoque de Cloudflare

Así funciona la nueva feature de Cloudflare:

  1. Request llega a Cloudflare edge con Accept: text/markdown
  2. Cloudflare hace fetch del HTML desde tu servidor de origen
  3. Parser genérico convierte HTML → Markdown usando heurísticas
  4. Resultado se cachea en el edge
  5. Se responde al cliente con markdown convertido
[Cliente con Accept:text/markdown]
    ↓
[Cloudflare Edge]
    ↓ fetch HTML
[Tu servidor: HTML completo]
    ↓ conversión
[HTML → Markdown parser]
    ↓
[Cache en edge]
    ↓
[Cliente recibe markdown]

Ventajas:

  • Implementación instantánea: solo activas un toggle
  • Funciona con cualquier CMS o stack backend
  • No requiere cambios en tu código
  • Cloudflare maneja el parsing

Desventajas:

  • Solo 80% de reducción (parte del HTML permanece)
  • Parser genérico puede malinterpretar estructuras complejas
  • Componentes custom (<Tabs>, <CodePlayground>) se pierden o convierten mal
  • Metadatos limitados (solo lo que está en HTML)
  • Sin control sobre el proceso de serialización
  • Requiere suscripción a Cloudflare

Ejemplo de conversión con pérdidas:

// Tu componente React personalizado
<CodePlayground
  language="javascript"
  initialCode="console.log('Hello')"
  showConsole={true}
/>

Cloudflare lo ve como HTML:

<div class="code-playground" data-language="javascript">
  <pre>console.log('Hello')</pre>
  <div class="console-output"></div>
</div>

Conversión resultante:

console.log('Hello')

Se perdieron: el contexto del playground, la interactividad, los atributos. Para un agente de IA, ahora es solo código suelto sin explicación.

Source conversion: el enfoque de este sitio

Source conversion significa servir el markdown original, sin conversión intermedia:

  1. Almacenas contenido en markdown (ej: _posts/{slug}/index.md)
  2. Request llega con Accept: text/markdown
  3. Lees el archivo markdown directamente (sin parsing HTML)
  4. Respondes con markdown + frontmatter tal como está almacenado
  5. Caches como cualquier otra respuesta
[Cliente con Accept:text/markdown]
    ↓
[Next.js Route Handler]
    ↓ lectura directa
[_posts/mi-articulo/index.md]
    ↓ sin conversión
[Cliente recibe markdown original]

Ventajas:

  • 97% de reducción: Sin overhead de HTML en absoluto
  • Fidelidad perfecta: Es el markdown fuente, sin interpretación
  • Metadatos completos: Frontmatter con todos los campos
  • Control total: Decides qué incluir/excluir
  • Gratis: No requiere servicios externos
  • Sin latencia adicional: Lectura directa del filesystem
  • Componentes custom: Decides cómo serializarlos

Desventajas:

  • Requiere que tu contenido esté en markdown (o convertible)
  • Necesitas implementar route handlers personalizados
  • No funciona "out of the box" como Cloudflare

Cuándo usar cada enfoque:

  • Edge Conversion si:

    • Ya usas Cloudflare
    • Tu contenido está en HTML puro (no tienes markdown fuente)
    • Necesitas implementación en 5 minutos
    • 80% de reducción es suficiente
  • Source Conversion si:

    • Usas Next.js, Astro, Hugo u otro generador con markdown
    • Quieres máxima reducción (97%)
    • Necesitas control total sobre serialización
    • Tienes componentes custom que requieren manejo especial

Este sitio usa source conversion porque el contenido ya está en markdown.

Implementación en Next.js: route handlers

La implementación completa requiere tres piezas:

  1. Route handlers para servir markdown
  2. Rewrites en next.config.mjs para content negotiation
  3. Parsing logic para extraer markdown y frontmatter

Estructura de archivos

_posts/
  ├── mi-articulo/
  │   └── index.md           # Post con frontmatter + contenido
app/
  ├── md/
  │   └── post/[slug]/
  │       └── route.ts       # Route handler para markdown
  ├── post/[slug]/
  │   └── page.tsx           # Página HTML tradicional
lib/
  ├── markdown.ts            # Parsing de markdown files
  └── posts.ts               # Funciones para obtener posts

next.config.mjs              # Rewrites para content negotiation

Route handler implementation

Creamos un route handler en app/md/post/[slug]/route.ts:

import { notFound } from 'next/navigation'
import { parseMarkdownFile } from '@/lib/markdown'
import type { NextRequest } from 'next/server'
 
export const runtime = 'nodejs'
export const dynamic = 'force-static'
export const revalidate = 2592000 // 30 días
 
export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
 
  try {
    // Leer y parsear el archivo markdown
    const parsed = await parseMarkdownFile(slug)
 
    if (!parsed) {
      notFound()
    }
 
    // Reconstruir frontmatter YAML
    const frontmatterLines = [
      '---',
      `title: ${parsed.frontmatter.title}`,
      `date: ${parsed.frontmatter.date}`,
      `category: ${parsed.frontmatter.category}`,
      `author: ${parsed.frontmatter.author?.name || 'Anonymous'}`,
      `excerpt: ${parsed.frontmatter.excerpt || ''}`,
      '---',
      '',
    ]
 
    const markdown = frontmatterLines.join('\n') + parsed.content
 
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, s-maxage=2592000, stale-while-revalidate',
        'Vary': 'Accept',
        'X-Content-Source': 'markdown',
      },
    })
  } catch (error) {
    console.error(`Error serving markdown for ${slug}:`, error)
    notFound()
  }
}
 
// Pre-generar todas las rutas en build time
export async function generateStaticParams() {
  const { getAllPosts } = await import('@/lib/posts')
  const posts = await getAllPosts()
 
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

Puntos clave:

  • runtime: 'nodejs': Route handler corre en Node.js (necesario para fs)
  • dynamic: 'force-static': Pre-renderiza todas las rutas en build time
  • revalidate: 2592000: Cache de 30 días (igual que páginas HTML)
  • Vary: Accept: Crucial para caching correcto en CDNs
  • generateStaticParams(): Pre-genera todas las URLs en build

Rewrites configuration

En next.config.mjs, configuramos rewrites para interceptar requests con Accept: text/markdown:

/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return {
      beforeFiles: [
        // 1. Soporte para extensión .md explícita
        //    GET /post/mi-articulo.md → /md/post/mi-articulo
        {
          source: '/post/:slug.md',
          destination: '/md/post/:slug',
        },
 
        // 2. Content negotiation vía Accept header
        //    GET /post/mi-articulo + Accept: text/markdown → /md/post/mi-articulo
        {
          source: '/post/:slug',
          destination: '/md/post/:slug',
          has: [
            {
              type: 'header',
              key: 'accept',
              value: '(.*text/markdown.*)',
            },
          ],
        },
      ],
    }
  },
}
 
export default nextConfig

Cómo funcionan los rewrites:

  • beforeFiles: Se ejecutan antes de verificar el filesystem
  • Primer rewrite: URLs con .md siempre van al route handler
  • Segundo rewrite: URLs sin .md van al handler solo si Accept contiene text/markdown
  • Si no coincide: Next.js continúa a la página HTML tradicional

Diagrama de flujo:

Request: GET /post/mi-articulo
         Accept: text/markdown
    ↓
[beforeFiles rewrites]
    ↓
¿Coincide /post/:slug + Accept:text/markdown?
    ↓ YES
[Rewrite a /md/post/mi-articulo]
    ↓
[Route handler: app/md/post/[slug]/route.ts]
    ↓
[Response: text/markdown]


Request: GET /post/mi-articulo
         Accept: text/html
    ↓
[beforeFiles rewrites]
    ↓
¿Coincide /post/:slug + Accept:text/markdown?
    ↓ NO
[Continúa a filesystem]
    ↓
[Página: app/post/[slug]/page.tsx]
    ↓
[Response: text/html]

Parsing markdown files

La función parseMarkdownFile() en lib/markdown.ts:

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
 
const postsDirectory = path.join(process.cwd(), '_posts')
 
export async function parseMarkdownFile(slug: string) {
  const fullPath = path.join(postsDirectory, slug, 'index.md')
 
  // Verificar que el archivo exista
  if (!fs.existsSync(fullPath)) {
    return null
  }
 
  try {
    const fileContents = fs.readFileSync(fullPath, 'utf8')
 
    // gray-matter separa frontmatter de contenido
    const { data, content } = matter(fileContents)
 
    return {
      frontmatter: data as MarkdownFrontmatter,
      content: content.trim(),
      slug,
    }
  } catch (error) {
    console.error(`Failed to parse markdown for ${slug}:`, error)
    return null
  }
}
 
// Type definitions
export interface MarkdownFrontmatter {
  title: string
  date: string
  category: string
  excerpt?: string
  author?: {
    name: string
    picture?: string
  }
  ogImage?: {
    url: string
  }
}

gray-matter es la biblioteca estándar para parsing de frontmatter. Maneja:

  • YAML frontmatter (entre ---, el formato por defecto)
  • JSON y JavaScript, indicando el lenguaje tras el delimitador (---json)
  • TOML u otros lenguajes, registrando un engine propio y, si hace falta, delimitadores personalizados

Ejemplo de archivo markdown:

---
title: "Mi Artículo Técnico"
date: "2026-02-13"
category: "Next.js"
excerpt: "Una breve descripción"
author:
  name: "angel cruz"
---
 
# Mi Artículo Técnico
 
Contenido del artículo aquí...
 
## Sección 1
 
Más contenido...

Parsing result:

{
  frontmatter: {
    title: "Mi Artículo Técnico",
    date: "2026-02-13",
    category: "Next.js",
    excerpt: "Una breve descripción",
    author: {
      name: "angel cruz"
    }
  },
  content: "# Mi Artículo Técnico\n\nContenido del artículo aquí...",
  slug: "mi-articulo-tecnico"
}

Manejo de componentes custom

Si tu contenido incluye componentes MDX o React, necesitas decidir cómo serializarlos para agentes de IA:

// En tu MDX:
<CodePlayground language="javascript" initialCode="console.log('Hello')" />

Opción 1: Reemplazar con markdown equivalente

// En route handler
const processedContent = content
  .replace(
    /<CodePlayground language="(\w+)" initialCode="([^"]+)" \/>/g,
    (_, lang, code) => `\`\`\`${lang}\n${code}\n\`\`\``
  )

Opción 2: Incluir como comentario

<!-- Interactive CodePlayground (JavaScript) -->
```javascript
console.log('Hello')
```
<!-- End CodePlayground -->

Opción 3: Anotar con metadatos

```javascript {interactive=true playground=true}
console.log('Hello')
```

Elige según tus necesidades. Con source conversion la serialización la controlas tú; con edge conversion, no.

Estrategia de caché

La misma estrategia de caché funciona para HTML y markdown.

Cache configuration

Tanto páginas HTML como route handlers markdown usan:

export const revalidate = 2592000 // 30 días
 
// Headers en response
'Cache-Control': 'public, s-maxage=2592000, stale-while-revalidate'

Esto significa:

  • CDN/Edge cache: 30 días
  • Stale-while-revalidate: Si el contenido expira, sirve versión stale mientras refrescas en background
  • Revalidación on-demand: Webhooks pueden invalidar cache manualmente

Webhook-based revalidation

Cuando actualizas contenido, un webhook desde tu CMS o backend invalida ambos caches:

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  // Verificar token de autenticación
  const token = request.headers.get('Authorization')?.replace('Bearer ', '')
 
  if (token !== process.env.REVALIDATE_TOKEN) {
    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
  }
 
  const body = await request.json()
  const { slug, type } = body
 
  if (type === 'post') {
    // Revalidar cache tags
    revalidateTag(`post-${slug}`, 'max')
    revalidateTag('posts-list', 'max')
 
    // Revalidar paths (HTML y markdown)
    revalidatePath(`/post/${slug}`)
    revalidatePath(`/md/post/${slug}`)
 
    return NextResponse.json({
      revalidated: true,
      paths: [`/post/${slug}`, `/md/post/${slug}`]
    })
  }
 
  return NextResponse.json({ message: 'Invalid type' }, { status: 400 })
}

Flujo completo:

[Editor actualiza post en CMS]
    ↓
[CMS envía webhook POST /api/revalidate]
    ↓
[Endpoint valida token]
    ↓
[revalidateTag('post-slug', 'max')]  ← Invalida fetch cache
[revalidatePath('/post/slug')] ← Invalida HTML page
[revalidatePath('/md/post/slug')] ← Invalida markdown route
    ↓
[Próxima request regenera contenido]

Cache tags para fetch()

Si usas fetch() dentro de componentes, aprovecha cache tags:

// En page.tsx o route.ts
const data = await fetch('https://api.example.com/posts', {
  next: {
    tags: ['posts', 'posts-list'],
    revalidate: 2592000,
  },
})

Luego en webhook:

revalidateTag('posts-list', 'max')  // Invalida todas las requests con ese tag

Vercel edge cache invalidation

Si estás en Vercel, puedes también invalidar edge cache vía API:

import { after } from 'next/server'
 
after(async () => {
  // Esto se ejecuta después de enviar response (non-blocking)
  await fetch(
    `https://api.vercel.com/v1/edge-cache/invalidate-by-tags?projectIdOrName=${process.env.VERCEL_PROJECT_ID}`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.VERCEL_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        tags: [`post-${slug}`],
      }),
    }
  )
})

Esto marca como stale en la CDN de Vercel todo lo que lleve ese tag, así que la siguiente request recibe contenido actualizado. La API de Vercel purga por cache tag, no por ruta: las respuestas tienen que llevar el tag, por ejemplo con cacheTag() de Next.js o el header Vercel-Cache-Tag, según la documentación de purga de Vercel.

Tres formas de acceder al contenido

Los agentes de IA (y usuarios) pueden acceder al markdown de tres formas:

1. Extensión .md explícita

La forma más simple y directa:

curl https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista.md

Response:

---
title: "Adminer: gestor de bases de datos minimalista"
date: "2024-01-15"
category: "Herramientas"
---
 
# Adminer: gestor de bases de datos minimalista
 
Adminer es una herramienta de gestión de bases de datos...

Ventajas:

  • URL explícita, fácil de compartir
  • No requiere headers especiales
  • Funciona en navegadores (descarga el markdown)

Uso:

# Descargar markdown localmente
curl -O https://www.angelcruz.dev/post/mi-articulo.md
 
# Ver en terminal
curl https://www.angelcruz.dev/post/mi-articulo.md | less

2. Header Accept: text/markdown

El método estándar de content negotiation:

curl -H "Accept: text/markdown" \
  https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista

Response headers:

HTTP/2 200
content-type: text/markdown; charset=utf-8
cache-control: public, s-maxage=2592000, stale-while-revalidate
vary: Accept
x-content-source: markdown

Ventajas:

  • URL estándar (misma que HTML)
  • SEO-friendly (no duplicación de URLs)
  • Método preferido por agentes de IA

Cómo lo usan los agentes:

// Claude Code (herramienta WebFetch) hace algo equivalente a:
const response = await fetch('https://www.angelcruz.dev/post/slug', {
  headers: {
    'Accept': 'text/markdown, text/html, */*',
    'User-Agent': 'Claude-User (<versión>; +https://support.anthropic.com/)',
  },
})
 
if (response.headers.get('content-type')?.includes('text/markdown')) {
  const markdown = await response.text()
  // Procesar markdown...
} else {
  // Fallback a HTML parsing
}

3. Descubrimiento vía sitemap

Puedes crear un sitemap específico para markdown:

<!-- /sitemap-markdown.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://www.angelcruz.dev/post/mi-articulo.md</loc>
    <lastmod>2026-02-13</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
  </url>
  <!-- Más posts... -->
</urlset>

Agentes de IA futuros podrían descubrir automáticamente contenido markdown via sitemap.

Resultados reales: benchmarks

Estas son mediciones reales de producción en este sitio.

Comparación de payload

Artículo: "Adminer: gestor de bases de datos minimalista"

# HTML completo
curl -sL https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista \
  | wc -c
316270 bytes
 
# Markdown puro
curl -sL https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista.md \
  | wc -c
1338 bytes

Reducción: 99.6%

Desglose del HTML (316KB)

Componente                  Tamaño      Porcentaje
─────────────────────────────────────────────────
Navigation                  ~25 KB      7.9%
Hero/Header                 ~15 KB      4.7%
Footer                      ~20 KB      6.3%
Sidebar                     ~30 KB      9.5%
CSS inlined (Tailwind)      ~80 KB      25.3%
JavaScript bundles          ~90 KB      28.5%
Meta tags + SEO             ~8 KB       2.5%
Contenido real (article)    ~30 KB      9.5%
Analytics + Scripts         ~18 KB      5.7%
─────────────────────────────────────────────────
Total                       316 KB      100%

El contenido real es solo 9.5% del payload.

Desglose del markdown (1.3KB)

Componente                  Tamaño      Porcentaje
─────────────────────────────────────────────────
Frontmatter (metadata)      ~200 bytes  15%
Contenido markdown          ~1138 bytes 85%
─────────────────────────────────────────────────
Total                       1338 bytes  100%

El contenido real es 85% del payload.

Token estimation

Usando el tokenizer de Claude (aproximado):

HTML completo:
- 316,270 bytes
- ~79,000 tokens (ratio: 4 bytes/token)

Markdown puro:
- 1,338 bytes
- ~335 tokens (ratio: 4 bytes/token)

Reducción de tokens: 99.58%

Performance impact

Métrica                     HTML        Markdown    Mejora
──────────────────────────────────────────────────────────
Tiempo de descarga (3G)     8.5s        0.04s       212x
Tiempo de parsing           ~150ms      ~5ms        30x
Memoria del agente          79KB        1.3KB       60x
Latencia total              8.65s       0.045s      192x

Agentes de IA que lo soportan

Soporte confirmado

Claude Code (Anthropic)

  • Su herramienta WebFetch envía Accept: text/markdown, text/html, */* por defecto
  • Usa markdown para reducir uso de contexto
  • Fallback a HTML parsing si no disponible
# Simular request de Claude Code
curl -H "Accept: text/markdown, text/html, */*" \
     -H "User-Agent: Claude-User" \
     https://www.angelcruz.dev/post/slug

OpenCode

  • Agente de código open source
  • Implementa mismo protocolo de content negotiation

Testing con curl

Puedes simular cualquier agente:

# Claude Code
curl -H "Accept: text/markdown, text/html, */*" \
     -H "User-Agent: Claude-User" \
     https://www.angelcruz.dev/post/slug
 
# OpenCode
curl -H "Accept: text/markdown" \
     -H "User-Agent: OpenCode/0.1" \
     https://www.angelcruz.dev/post/slug
 
# Generic AI Agent
curl -H "Accept: text/markdown" \
     -H "User-Agent: Mozilla/5.0 (AI Agent)" \
     https://www.angelcruz.dev/post/slug

Ventajas adicionales

1. Mejor precisión en RAG

RAG (Retrieval-Augmented Generation) mejora con markdown limpio:

Estudio de caso: Sistema RAG con 10,000 artículos técnicos

Input: HTML completo
- Chunk size: 2000 tokens
- Chunks por artículo: ~40
- Retrieval accuracy: 62%

Input: Markdown puro
- Chunk size: 2000 tokens
- Chunks por artículo: ~2
- Retrieval accuracy: 89%

Mejora: +27 puntos porcentuales

¿Por qué? Porque markdown:

  • No tiene ruido de navegación confundiendo embeddings
  • Estructura semántica clara para vector search
  • Metadata útil en frontmatter

2. Compatibilidad con llms.txt

El archivo /llms.txt es un estándar emergente para descubrimiento de contenido por AI:

# Mi sitio
 
> Blog técnico. Cada URL de contenido acepta el sufijo .md.
 
## Posts
 
- [Mi artículo](https://www.angelcruz.dev/post/mi-articulo.md)
- [Otro artículo](https://www.angelcruz.dev/post/otro-articulo.md)
 
## Lab
 
- [IndexNow no sirve para Google](https://www.angelcruz.dev/lab/indexnow-no-sirve-para-google.md)
 
## Categorías
 
- [Next.js](https://www.angelcruz.dev/categorias/nextjs.md)

Agentes de IA pueden:

  1. Leer llms.txt
  2. Descubrir URLs markdown
  3. Fetch contenido directamente (sin HTML parsing)

3. Sin duplicación de archivos

A diferencia de mantener .html y .md separados:

Approach incorrecto:
content/
  ├── mi-articulo.md      ← Fuente
  └── mi-articulo.html    ← Generado

Problem: Sync issues, doble storage, potencial inconsistencia

Con source conversion:

Approach correcto:
_posts/
  └── mi-articulo/
      └── index.md        ← Single source of truth

Generado on-the-fly:
- GET /post/mi-articulo         → HTML (rendered)
- GET /post/mi-articulo.md      → Markdown (raw)

4. Control total sobre serialización

Puedes customizar cómo serializar componentes complejos:

// app/md/post/[slug]/route.ts
 
function serializeCustomComponents(content: string): string {
  // Convertir <Tabs> a markdown equivalente
  content = content.replace(
    /<Tabs items=\[(.*?)\]>(.*?)<\/Tabs>/gs,
    (_, items, innerContent) => {
      const tabs = JSON.parse(`[${items}]`)
      let markdown = '\n'
      tabs.forEach((tab: string, i: number) => {
        markdown += `### Tab: ${tab}\n\n`
        // Extraer contenido del tab...
      })
      return markdown
    }
  )
 
  // Convertir <Callout> a blockquote
  content = content.replace(
    /<Callout type="(.*?)">(.*?)<\/Callout>/gs,
    (_, type, text) => `> **${type.toUpperCase()}**: ${text}\n\n`
  )
 
  return content
}

Edge conversion (Cloudflare) no te deja meter lógica custom como esta.

5. Faster development cycle

Durante desarrollo local:

# Iniciar dev server
pnpm dev
 
# Probar markdown endpoint
curl http://localhost:3000/post/mi-articulo.md
 
# Ver cambios en tiempo real (hot reload)

No necesitas esperar a despliegue en Cloudflare para probar.

Comparación con otras soluciones

Cloudflare Markdown for Agents

Pros:

  • Setup instantáneo (dashboard toggle)
  • Funciona con cualquier stack
  • Mantenido por Cloudflare

Cons:

  • Solo 80% reducción
  • Parser genérico (pérdida de fidelidad)
  • Requiere suscripción Cloudflare
  • Sin control sobre serialización

Cuándo usar: Si necesitas solución rápida y ya usas Cloudflare.

Firecrawl API

Servicio de "scraping inteligente" que convierte sitios a markdown:

Pros:

  • API simple
  • Maneja JavaScript rendering
  • Extrae contenido estructurado

Cons:

  • De pago por volumen: cobra en créditos, 1 crédito por página en un scrape básico, con 1.000 créditos gratis al mes (precios de Firecrawl)
  • Latencia alta (~2-5 segundos)
  • Límites de rate
  • No es real-time

Cuándo usar: Para scraping de sitios externos que no controlas.

Crawl4AI (Self-Hosted)

Librería open-source para web scraping con AI:

Pros:

  • Gratis (self-hosted)
  • Flexible y customizable
  • Soporte para JavaScript

Cons:

  • Requiere infraestructura (Docker, servidores)
  • Mantenimiento necesario
  • Latencia de parsing
  • No es source conversion

Cuándo usar: Para agregar contenido de múltiples fuentes.

Apify Scrapers

Plataforma de web scraping as a service:

Pros:

  • Scrapers pre-configurados
  • Maneja anti-bot protections
  • Infraestructura escalable

Cons:

  • Costoso a escala
  • No real-time
  • Enfocado en scraping, no content delivery

Cuándo usar: Para proyectos de data mining.

Source conversion (este enfoque)

Pros:

  • 97% reducción (máximo)
  • Gratis (built-in Next.js)
  • Fidelidad perfecta
  • Control total
  • Real-time

Cons:

  • Requiere implementación custom
  • Solo funciona si tienes markdown fuente

Cuándo usar: Si usas Next.js/Astro/Hugo y tienes markdown.

Consideraciones SEO

¿Afecta content negotiation al SEO?

No. Content negotiation es un estándar HTTP que Google soporta:

  1. Mismo contenido, diferente representación: Google ve esto como equivalente a servir JSON vs XML en APIs
  2. Header Vary: Accept indica variaciones: Le dice a Google que hay múltiples versiones según Accept
  3. No es cloaking: Cloaking es servir contenido diferente intencionalmente para engañar; content negotiation es negociación explícita

Comparación: content negotiation vs cloaking

Content Negotiation (Permitido):
   Request: Accept: text/markdown
   Response: Markdown del mismo contenido
   Razón: Cliente pidió explícitamente ese formato

Cloaking (Penalizado):
   Request: User-Agent: Googlebot
   Response: Contenido optimizado solo para bot
   Razón: Engañar al bot mostrando algo distinto al usuario

Canonical URLs

Si ofreces .md URLs, usa canonical:

<!-- En versión HTML -->
<link rel="canonical" href="https://www.angelcruz.dev/post/mi-articulo">
 
<!-- En versión markdown -->
<!-- No aplicable: markdown no tiene <head> -->

Alternativamente, sirve markdown solo via header, no como URL separada.

Beneficios SEO futuros

Perplexity, You.com y Bing AI ya usan LLMs para procesar contenido web. Servir markdown reduce los tokens necesarios para entender un artículo, lo que puede mejorar la comprensión del contenido por parte de estos sistemas.

Los AI agents también pueden indexar contenido más profundamente cuando reciben markdown limpio en lugar de HTML con markup de navegación. Más contexto útil por token significa mejores respuestas y más referencias a tu sitio.

Implementación paso a paso

Guía rápida para implementar en tu proyecto Next.js.

Paso 1: Verificar estructura de contenido

Asegúrate de tener markdown source:

# Estructura esperada
_posts/
  ├── mi-articulo/
  │   └── index.md
  ├── otro-articulo/
  │   └── index.md

Si no tienes markdown, considera:

  • Migrar desde CMS (WordPress, Contentful) a markdown
  • O usar edge conversion (Cloudflare) en su lugar

Paso 2: Crear route handler

mkdir -p app/md/post/[slug]
touch app/md/post/[slug]/route.ts

Contenido de route.ts:

import { notFound } from 'next/navigation'
import { parseMarkdownFile } from '@/lib/markdown'
 
export const runtime = 'nodejs'
export const dynamic = 'force-static'
export const revalidate = 2592000
 
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const parsed = await parseMarkdownFile(slug)
 
  if (!parsed) notFound()
 
  const frontmatterLines = [
    '---',
    `title: ${parsed.frontmatter.title}`,
    `date: ${parsed.frontmatter.date}`,
    '---',
    '',
  ]
 
  const markdown = frontmatterLines.join('\n') + parsed.content
 
  return new Response(markdown, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': 'public, s-maxage=2592000, stale-while-revalidate',
      'Vary': 'Accept',
    },
  })
}

Paso 3: Configurar rewrites

En next.config.mjs:

export default {
  async rewrites() {
    return {
      beforeFiles: [
        {
          source: '/post/:slug.md',
          destination: '/md/post/:slug',
        },
        {
          source: '/post/:slug',
          destination: '/md/post/:slug',
          has: [
            {
              type: 'header',
              key: 'accept',
              value: '(.*text/markdown.*)',
            },
          ],
        },
      ],
    }
  },
}

Paso 4: Test con curl

# Terminal 1: Iniciar dev server
pnpm dev
 
# Terminal 2: Probar endpoints
curl http://localhost:3000/post/mi-articulo.md
 
curl -H "Accept: text/markdown" \
  http://localhost:3000/post/mi-articulo

Deberías ver markdown puro, no HTML.

Paso 5: Agregar a cache revalidation

En app/api/revalidate/route.ts:

if (type === 'post') {
  revalidateTag(`post-${slug}`, 'max')
  revalidatePath(`/post/${slug}`)
  revalidatePath(`/md/post/${slug}`)  // ← Agregar esta línea
}

Paso 6: (Opcional) Crear sitemap markdown

// app/sitemap-markdown.xml/route.ts
export async function GET() {
  const posts = await getAllPosts()
 
  const urls = posts.map(post => ({
    loc: `https://www.angelcruz.dev/post/${post.slug}.md`,
    lastmod: post.date,
    changefreq: 'monthly',
    priority: 0.8,
  }))
 
  const xml = generateSitemapXML(urls)
 
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

Paso 7: (Opcional) Agregar a llms.txt

# Mi sitio
 
> Blog técnico en markdown.
 
## Posts
 
- [Mi artículo](https://www.angelcruz.dev/post/mi-articulo.md)
- [Otro artículo](https://www.angelcruz.dev/post/otro-articulo.md)
 
## Descubrimiento
 
- [Sitemap markdown](https://www.angelcruz.dev/sitemap-markdown.xml): todos los posts

Ese archivo va en public/llms.txt y sigue el formato de llmstxt.org: un H1 con el nombre del sitio, un resumen en blockquote y secciones H2 con listas de enlaces.

Paso 8: Deploy y verificar

# Build production
pnpm build
 
# Deploy (Vercel)
vercel --prod
 
# Verificar en producción
curl -H "Accept: text/markdown" \
  https://tu-sitio.dev/post/mi-articulo

Servir markdown es una pieza de un terreno más grande: cómo los agentes descubren, autorizan y leen la web. El resto está en la guía de agentes de IA.

FAQ

¿Esto afecta mi SEO normal en Google?

No. Los navegadores tradicionales reciben HTML como siempre. Solo agentes con Accept: text/markdown reciben markdown. Google no penaliza content negotiation legítimo.

¿Funciona con SSG (Static Site Generation)?

Sí. Usa dynamic: 'force-static' en tu route handler y generateStaticParams() para pre-generar todas las rutas en build time.

¿Funciona con ISR (Incremental Static Regeneration)?

Sí. El revalidate en route handler funciona igual que en pages.

¿Qué pasa con las imágenes en el markdown?

Las URLs de imágenes se preservan. Los agentes de IA pueden decidir si descargarlas. Ejemplo:

![Diagrama de arquitectura](https://cdn.example.com/image.png)

El agente puede:

  • Ignorar la imagen (solo procesar texto)
  • Descargarla y analizarla (si soporta visión)
¿Es compatible con WordPress?

WordPress no implementa content negotiation de forma nativa. Las alternativas más comunes son:

  1. Custom endpoint: REST API que convierte HTML → Markdown bajo demanda
  2. Usando Cloudflare: Edge conversion si ya tienes Cloudflare delante del sitio
¿Vale la pena vs. Cloudflare?

Usa Source Conversion si:

  • Ya usas Next.js + markdown
  • Quieres máxima reducción (97%)
  • Necesitas control total

Usa Cloudflare si:

  • Tu contenido es HTML puro (CMS tradicional)
  • Quieres setup en 5 minutos
  • 80% reducción es suficiente
¿Cómo manejo autenticación en posts privados?
// app/md/post/[slug]/route.ts
export async function GET(request: Request) {
  const token = request.headers.get('Authorization')
 
  // Validar token
  const user = await validateToken(token)
  if (!user) return new Response('Unauthorized', { status: 401 })
 
  // Verificar acceso al post
  const post = await getPost(slug)
  if (post.private && !user.hasPaidAccess) {
    return new Response('Forbidden', { status: 403 })
  }
 
  // Servir markdown
  return new Response(markdown, { headers: { ... } })
}
¿Puedo servir otros formatos (JSON, PDF)?

Sí. Content negotiation soporta cualquier MIME type:

const acceptHeader = request.headers.get('Accept')
 
if (acceptHeader?.includes('application/json')) {
  return Response.json({ title, content, metadata })
}
 
if (acceptHeader?.includes('application/pdf')) {
  const pdf = await generatePDF(content)
  return new Response(pdf, {
    headers: { 'Content-Type': 'application/pdf' }
  })
}
 
// Default: HTML
return new Response(htmlContent)
¿Cómo monitoreo uso de markdown endpoints?
// app/md/post/[slug]/route.ts
export async function GET(request: Request) {
  // Log analytics
  await trackEvent({
    event: 'markdown_request',
    slug,
    userAgent: request.headers.get('User-Agent'),
    referrer: request.headers.get('Referer'),
  })
 
  // Servir contenido...
}

O usa un proxy (el archivo que hasta Next.js 15 se llamaba middleware.ts, según la documentación de Next.js):

// proxy.ts
export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/md/')) {
    // Track markdown requests
    console.log('Markdown request:', {
      path: request.nextUrl.pathname,
      agent: request.headers.get('User-Agent'),
    })
  }
}

Conclusión

Las páginas HTML gastan entre el 80% y el 99% de los tokens en markup que al modelo no le dice nada. La forma estándar de arreglarlo es content negotiation con el header Accept: text/markdown, y hay dos maneras de servirlo: convertir en el edge, que recorta un 80%, o servir el markdown desde la fuente, que recorta un 97%.

Este sitio usa la segunda porque el contenido ya vive en markdown, así que no hay conversión ni duplicación: un route handler que lee el archivo .md y unos rewrites. En la medición de febrero de 2026, el resultado fue de 316KB a 1.3KB, de unos 80.000 tokens a 350.

Puedes comprobarlo contra cualquier artículo:

curl -H "Accept: text/markdown" \
  https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista
 
# o con la extensión .md
curl https://www.angelcruz.dev/post/adminer-gestor-de-bases-de-datos-minimalista.md

Para copiarlo, en este sitio el código está repartido en dos lugares: el route handler en app/md/post/[slug]/route.ts y los rewrites en next.config.ts.

Y esto no se queda en HTTP. Hay propuestas para bajarlo a la capa de DNS, como DNS-AID, que deja a los agentes descubrir endpoints mediante registros DNS firmados antes de la primera petición.


Si quieres ver esto montado y en producción, con las decisiones de implementación y el inventario completo de lo que expone el sitio, está en superficies para agentes.

Referencias

Documentación oficial

  1. Cloudflare: Markdown for Agents - Anuncio oficial de la feature de Cloudflare
  2. Vercel: Agent-Friendly Pages - Guía de Vercel sobre content negotiation
  3. Next.js: Route Handlers - Documentación oficial de route handlers
  4. Next.js: Rewrites - Documentación de rewrites en Next.js
  5. Next.js: Caching - Sistema de caché en App Router

Estándares y especificaciones HTTP

  1. RFC 9110: HTTP Semantics - Content Negotiation - Especificación oficial de HTTP
  2. MDN: HTTP Content Negotiation - Guía de MDN sobre content negotiation
  3. MDN: Accept Header - Documentación del header Accept
  4. MDN: Vary Header - Documentación del header Vary
  5. IANA Media Types - Registro oficial de text/markdown

Otras implementaciones

  1. Cloudflare Docs: Markdown for Agents - Funcionamiento, planes y límites de la conversión en el edge
  2. Sanity.io: Portable Text to Markdown - Conversión de contenido estructurado
  3. gray-matter GitHub - Parsing de frontmatter YAML
  4. unified GitHub - Pipeline de procesamiento de markdown
  5. Shiki Documentation - Syntax highlighter usado en este sitio

Mediciones y análisis

  1. Claude Code: documentación - Documentación de Claude Code
  2. Token Reduction Benchmarks - Mediciones de Cloudflare
  3. Precios de la API de Claude - Costo por token de entrada
  4. RAG with Clean Text - RAG best practices
  5. Web Scraping vs Source Conversion - Comparación de enfoques

Herramientas y librerías

  1. Firecrawl API - Servicio de HTML to Markdown
  2. Crawl4AI GitHub - Self-hosted scraping
  3. Turndown GitHub - HTML to Markdown converter
  4. remark GitHub - Markdown processor
  5. rehype GitHub - HTML processor

SEO y estándares

  1. Google: Content Negotiation Best Practices - Guía de Google sobre URLs duplicadas
  2. llms.txt Spec - Estándar emergente para descubrimiento AI
  3. Schema.org: Article - Structured data para artículos

¿Tienes un proyecto en mente?

Trabajo con Laravel, WordPress, SEO técnico y servidores MCP. El primer paso es una llamada de descubrimiento, sin costo ni compromiso, donde me cuentas qué necesitas y te digo con honestidad si puedo ayudarte.

Hablemos