
Fredy Acuna / October 30, 2026 / 6 min read
llms.txt is a proposal for a file at the root of a website, /llms.txt, that describes the site in plain markdown for language models. Think of it as a short, curated table of contents: a title, a one-paragraph summary, and lists of links with a sentence about each one.
The motivation is practical. HTML pages are full of navigation, scripts and layout that an LLM has to wade through, and a context window is limited. A small markdown file gives a model (or an AI agent browsing on someone's behalf) a clean map of what the site contains and where to find it.
A note on expectations: llms.txt is a community proposal, not a web standard like robots.txt or sitemaps. Some AI tools and agents read it, but I would not assume any particular search engine or crawler uses it. I added it because it is cheap, it is useful when you point an assistant at your site, and it costs nothing to maintain if you generate it.
The proposal is simple. In order:
H1 with the name of the site or project (the only required part)H2 sections containing markdown lists of links, each optionally followed by : description## Optional for links that can be skipped when context is shortA minimal example:
# 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)
You could drop a static public/llms.txt file into your project and be done. The problem is that it starts going stale the day you publish your next post. My site has posts and projects in two languages, so a hand-written file would be wrong within a week.
Next.js already gives me everything I need to build it on request: the same getPosts() and getProjects() functions the blog uses, and a site config with the canonical URL and social links. A route handler can stitch those together.
In the App Router, a folder named llms.txt with a route.ts inside serves /llms.txt. The dot in the folder name is fine.
app/
└── llms.txt/
└── route.ts
Here is the full handler from my portfolio:
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'
}
})
}
Let me walk through the decisions.
The link helper produces a list item in the exact shape the format expects: - [title](url): summary. Every post and project already has a summary in its frontmatter (I write them for SEO and social cards anyway), so each entry gets a meaningful description for free.
Spanish posts live under /publicaciones/ and English ones under /posts/. The handler picks the segment from each item's locale, so a model following a link lands on a URL that returns 200 in the right language, not on a redirect.
All links are built from siteConfig.siteUrl, the same value used by the sitemap, robots and canonical tags. If the canonical host ever changes, every one of them changes together. (I learned that the hard way when my sitemap pointed to a host that redirected; that story is in Why Your Next.js Sitemap Is Empty.)
## Optional: the two RSS feeds and my social profilesThe Optional section is meant for things a model can skip when it is short on context. Feeds and social profiles fit that well.
Content-Type: text/plain; charset=utf-8: the file is markdown text. The charset matters because titles and summaries contain accents and characters like the en dash.Cache-Control: s-maxage=3600, stale-while-revalidate: the CDN can cache it for an hour and serve the cached copy while it refreshes in the background. The content only changes when I deploy a new post, so there is no reason to rebuild it on every request.Run the dev server and request it:
curl -i http://localhost:3000/llms.txt
Check that:
Content-Type is text/plain; charset=utf-8## Postsapp/llms.txt/route.ts serves it and can reuse the same content functions as your blogtext/plain with a UTF-8 charset and a sensible CDN cache