Fredy Acuna
  • Posts
  • Projects
  • Tools
  • Contact
LinkedInXGitHubMedium

© 2026 Fredy Acuna

Back to posts
How to Add llms.txt to a Next.js Site (Generated From Your Content)

How to Add llms.txt to a Next.js Site (Generated From Your Content)

Fredy Acuna / October 30, 2026 / 6 min read

What Is llms.txt?

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.


What You'll Learn

  • The structure an llms.txt file follows
  • How to serve it from a Next.js route handler
  • How to generate it from your MDX content so it never goes stale
  • Which headers to send

The Format

The proposal is simple. In order:

  1. An H1 with the name of the site or project (the only required part)
  2. A blockquote with a short summary
  3. Optional free-form text
  4. H2 sections containing markdown lists of links, each optionally followed by : description
  5. An optional section called ## Optional for links that can be skipped when context is short

A 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)

Why Generate It Instead of Writing It

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.


The Route Handler

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.

One Small Helper for Every Link

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.

Each Item Points to Its Own Language

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.

Absolute URLs From One Place

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.)

What It Lists

  • The main pages, with their English and Spanish versions side by side
  • Every post, with its summary
  • Every project, with its summary
  • My certification, as a link to the verifiable badge
  • Under ## Optional: the two RSS feeds and my social profiles

The Optional section is meant for things a model can skip when it is short on context. Feeds and social profiles fit that well.

Headers

  • 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.

Testing It

Run the dev server and request it:

curl -i http://localhost:3000/llms.txt

Check that:

  • The status is 200 and the Content-Type is text/plain; charset=utf-8
  • The newest post appears under ## Posts
  • The links use your canonical host (with or without www, whichever returns 200)

Key Takeaways

  • llms.txt is a short markdown index of your site for LLMs, proposed at llmstxt.org; treat it as a useful extra, not as an SEO requirement
  • A route handler at app/llms.txt/route.ts serves it and can reuse the same content functions as your blog
  • Generating it from content means it updates with every deploy and never goes stale
  • Build every URL from the same canonical site URL as your sitemap and canonical tags
  • Serve it as text/plain with a UTF-8 charset and a sensible CDN cache

Subscribe to my newsletter

Get notified when I publish new posts.

We care about your data. Read our privacy policy.