
Fredy Acuna / October 30, 2026 / 6 min read
llms.txt es una propuesta para un archivo en la raíz de un sitio web, /llms.txt, que describe el sitio en markdown plano para modelos de lenguaje. Piénsalo como una tabla de contenido corta y curada: un título, un resumen de un párrafo y listas de enlaces con una frase sobre cada uno.
La motivación es práctica. Las páginas HTML están llenas de navegación, scripts y maquetación que un LLM tiene que atravesar, y la ventana de contexto es limitada. Un archivo markdown pequeño le da a un modelo (o a un agente de IA que navega en nombre de alguien) un mapa limpio de lo que contiene el sitio y dónde encontrarlo.
Una aclaración sobre las expectativas: llms.txt es una propuesta de la comunidad, no un estándar web como robots.txt o los sitemaps. Algunas herramientas y agentes de IA lo leen, pero no asumiría que un buscador o crawler en particular lo usa. Lo agregué porque es barato, es útil cuando le pides a un asistente que revise tu sitio y no cuesta nada mantenerlo si lo generas.
La propuesta es simple. En orden:
H1 con el nombre del sitio o proyecto (la única parte obligatoria)H2 con listas de enlaces en markdown, cada uno opcionalmente seguido de : descripción## Optional para enlaces que se pueden omitir cuando hay poco contextoUn ejemplo mínimo:
# Fredy Acuna
> Personal portfolio and blog of Fredy Acuna, Software Engineer.
## Posts
- [Why Your Next.js Sitemap Is Empty](https://www.fredhii.com/posts/why-your-nextjs-sitemap-is-empty): next-sitemap fails when every page renders on request.
## Optional
- [RSS (EN)](https://www.fredhii.com/feed.xml)
Podrías poner un archivo estático public/llms.txt en tu proyecto y listo. El problema es que empieza a desactualizarse el día que publicas tu siguiente artículo. Mi sitio tiene publicaciones y proyectos en dos idiomas, así que un archivo escrito a mano estaría mal en menos de una semana.
Next.js ya me da todo lo necesario para construirlo en cada petición: las mismas funciones getPosts() y getProjects() que usa el blog, y una configuración del sitio con la URL canónica y los enlaces sociales. Un route handler puede unir todo eso.
En el App Router, una carpeta llamada llms.txt con un route.ts adentro sirve /llms.txt. El punto en el nombre de la carpeta no es problema.
app/
└── llms.txt/
└── route.ts
Este es el handler completo de mi portafolio:
import { getPosts } from '@/lib/posts'
import { getProjects } from '@/lib/projects'
import { siteConfig } from '@/lib/config'
// llms.txt (https://llmstxt.org): a plain-markdown index of the site for LLMs.
export async function GET() {
const [posts, projects] = await Promise.all([getPosts(), getProjects()])
const { siteUrl, author, social } = siteConfig
const link = (title: string | undefined, path: string, summary?: string) =>
`- [${title || 'Untitled'}](${siteUrl}${path})${summary ? `: ${summary}` : ''}`
const body = `# ${author.name}
> Personal portfolio and blog of ${author.name}, Software Engineer sharing insights on web development, technology, and projects. Content is published in English and Spanish.
Certifications: [AWS Certified Solutions Architect – Associate](${social.credly})
## Pages
- [Home](${siteUrl})
- [Posts (EN)](${siteUrl}/posts) / [Publicaciones (ES)](${siteUrl}/publicaciones)
- [Projects (EN)](${siteUrl}/projects) / [Proyectos (ES)](${siteUrl}/proyectos)
- [Tools (EN)](${siteUrl}/tools) / [Herramientas (ES)](${siteUrl}/herramientas)
- [Contact](${siteUrl}/contact)
## Posts
${posts.map(p => link(p.title, `/${p.locale === 'es' ? 'publicaciones' : 'posts'}/${p.slug}`, p.summary)).join('\n')}
## Projects
${projects.map(p => link(p.title, `/${p.locale === 'es' ? 'proyectos' : 'projects'}/${p.slug}`, p.summary)).join('\n')}
## Optional
- [RSS (EN)](${siteUrl}/feed.xml)
- [RSS (ES)](${siteUrl}/feed.es.xml)
- [GitHub](${social.github})
- [LinkedIn](${social.linkedin})
`
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 's-maxage=3600, stale-while-revalidate'
}
})
}
Repasemos las decisiones.
El helper link genera un elemento de lista con la forma exacta que espera el formato: - [título](url): resumen. Cada publicación y proyecto ya tiene un summary en su frontmatter (los escribo de todos modos para SEO y redes sociales), así que cada entrada obtiene una descripción útil sin esfuerzo extra.
Las publicaciones en español viven bajo /publicaciones/ y las de inglés bajo /posts/. El handler elige el segmento según el locale de cada elemento, así que un modelo que sigue un enlace llega a una URL que responde 200 en el idioma correcto, no a una redirección.
Todos los enlaces se construyen con siteConfig.siteUrl, el mismo valor que usan el sitemap, el robots y las etiquetas canonical. Si el host canónico cambia, todos cambian juntos. (Lo aprendí por las malas cuando mi sitemap apuntaba a un host que redirigía; esa historia está en Por Qué Tu Sitemap de Next.js Está Vacío.)
## Optional: los dos feeds RSS y mis perfiles socialesLa sección Optional está pensada para lo que un modelo puede omitir cuando tiene poco contexto. Los feeds y los perfiles sociales encajan bien ahí.
Content-Type: text/plain; charset=utf-8: el archivo es texto en markdown. El charset importa porque los títulos y resúmenes tienen tildes y caracteres como la raya.Cache-Control: s-maxage=3600, stale-while-revalidate: la CDN puede guardarlo en caché una hora y servir la copia guardada mientras se actualiza en segundo plano. El contenido solo cambia cuando despliego un artículo nuevo, así que no hay razón para reconstruirlo en cada petición.Levanta el servidor de desarrollo y pídelo:
curl -i http://localhost:3000/llms.txt
Verifica que:
Content-Type sea text/plain; charset=utf-8## Postsapp/llms.txt/route.ts lo sirve y puede reutilizar las mismas funciones de contenido que tu blogtext/plain con charset UTF-8 y una caché razonable en la CDN