KnownByLLM

Tutorial · 10 min read

Generating llms.txt at build time in Next.js, Astro, and Hugo

The smallest working code for each framework, and one source list shared with the sitemap.

None of the three frameworks ships an llms.txt convention. Each one, though, already has a way to emit an arbitrary text file at build time from the same data that renders your pages, which is exactly what a file listing your pages needs. This is a tutorial for that: one route handler, one endpoint, one output format.

The Next.js example is the code this site runs. The Astro and Hugo examples follow each framework’s current documentation. The last section is the part that keeps the file honest: driving it from the same list as the sitemap.

The 30-second answer: generate llms.txt in Next.js, Astro, and Hugo from the same list that feeds your sitemap

In Next.js, a Route Handler at app/llms.txt/route.ts returns the text; add export const dynamic = "force-static" so it renders once at build. In Astro, an endpoint at src/pages/llms.txt.ts exports a GET that returns a Response; in a static build it is written to dist/llms.txt. In Hugo, a custom output format named llms with base name llms and media type text/plain, added to the home page outputs, renders layouts/index.llms.txt to /llms.txt.

The generation is the easy part. The decision that matters is what goes in: reuse the list that already builds your sitemap, then filter it down to the sections a new visitor should see and write one sentence per link. A hand-written file in public/ is fine for a small site; the code below is for sites that add pages often enough that the file would go stale.

Static file or generated?

Next.jsAstroHugo
Hand-written filepublic/llms.txtpublic/llms.txtstatic/llms.txt
Generated at buildapp/llms.txt/route.ts with force-staticsrc/pages/llms.txt.ts endpointCustom output format on the home page
Data sourceYour own routes list or CMS fetchgetCollection from astro:content.Site.RegularPages in the template
Content-Type controlSet in the Response headersHost decides by extension in static buildsHost decides by extension
Built-in convention?No (robots.ts and sitemap.ts exist, llms.txt does not)No (community integrations exist)No (feature request closed as duplicate)

Next.js: a force-static Route Handler

This is the shape of the file this site serves at /llms.txt. A folder named llms.txt is a valid route segment, and the handler returns a plain Response with the Markdown body and the headers you want.

// app/llms.txt/route.ts
import { ROUTES } from "@/lib/routes";

export const dynamic = "force-static";

export function GET() {
  const lines = [
    "# Acme",
    "",
    "> Acme is a scheduling tool for small clinics. These pages explain setup, pricing, and integrations.",
    "",
    "## Guides",
    ...ROUTES.filter((r) => r.section === "guides").map(
      (r) => `- [${r.title}](https://acme.example${r.path}): ${r.description}`,
    ),
    "",
    "## Optional",
    "- [Changelog](https://acme.example/changelog): release notes, newest first.",
    "",
  ];
  return new Response(lines.join("\n"), {
    headers: {
      "Content-Type": "text/markdown; charset=utf-8",
      "Cache-Control": "public, max-age=3600, s-maxage=86400",
    },
  });
}

Two details. Since Next.js 15 a GET handler is dynamic by default, so without force-static the file would be rendered on every request; with it, the build writes the response once. And the App Router has no llms.txt convention the way it has robots.ts and sitemap.ts, so the handler is the supported way; the Next.js documentation site serves its own index the same way at /docs/llms.txt.

Astro: an endpoint that reads your content collections

A .ts file under src/pages/ whose name ends in .txt.ts becomes a .txt URL. In a static build Astro calls the endpoint once and writes the result to dist/llms.txt.

// src/pages/llms.txt.ts
import type { APIRoute } from "astro";
import { getCollection } from "astro:content";

export const GET: APIRoute = async ({ site }) => {
  const docs = (await getCollection("docs", ({ data }) => !data.draft))
    .sort((a, b) => a.data.order - b.data.order);

  const body = [
    "# Acme",
    "",
    "> Acme is a scheduling tool for small clinics. These pages explain setup, pricing, and integrations.",
    "",
    "## Guides",
    ...docs.map((d) => `- [${d.data.title}](${new URL(`/docs/${d.id}/`, site)}): ${d.data.description}`),
    "",
  ].join("\n");

  return new Response(body, {
    headers: { "Content-Type": "text/markdown; charset=utf-8" },
  });
};

The site value comes from astro.config, which is what makes the links absolute. The filter on draft is the same rule the sitemap integration applies, so the two files agree on what is published. The header only matters for server-rendered output; on a static host the extension decides.

Hugo: a custom output format on the home page

Hugo can render any page kind into any number of formats. Define one for llms.txt, add it to the home page’s outputs, and write a template for it. text/plain is a built-in media type with the txt suffix, which is what makes the file name come out as llms.txt.

# hugo.toml
[outputFormats.llms]
  mediaType = "text/plain"
  baseName = "llms"
  isPlainText = true
  notAlternative = true

[outputs]
  home = ["html", "rss", "llms"]
{{/* layouts/index.llms.txt */}}
# {{ .Site.Title }}

> {{ .Site.Params.description }}

## Guides
{{ range where .Site.RegularPages "Section" "guides" }}
- [{{ .Title }}]({{ .Permalink }}): {{ .Description | default .Summary | plainify }}
{{- end }}

## Optional
{{ range first 10 (where .Site.RegularPages "Section" "posts") }}
- [{{ .Title }}]({{ .Permalink }}): {{ .Description | default .Summary | plainify }}
{{- end }}

Setting outputs.home replaces the default list, so keep html and rss in it. notAlternative keeps the file out of the rel=alternate links your theme may emit in the HTML head. .Site.RegularPages already excludes drafts and future-dated content, so the list matches what Hugo puts in the sitemap.

Keeping it in sync with the sitemap

The failure mode of a hand-written llms.txt is not that it is wrong on day one but that it is wrong six months later. The fix is structural: one list of routes, consumed by both files.

  • Next.js: keep a lib/routes.ts that exports path, title, description, and section for each page. app/sitemap.ts maps it to sitemap entries; app/llms.txt/route.ts maps a filtered subset to Markdown lines. Adding a page means adding one object.
  • Astro: both the sitemap integration and the endpoint see the same content collections, so the shared source already exists. Apply the same draft filter in both and give every entry a frontmatter description; the endpoint uses it as the link annotation.
  • Hugo: .Site.RegularPages feeds both the built-in sitemap template and your llms template. Use front-matter description for the annotation and a section filter, not a hand-maintained list of paths.

Sharing the list does not mean copying the sitemap. The sitemap lists everything; llms.txt lists the sections a first-time reader needs, in the order you would explain them, with a sentence each. The filter and the descriptions are the editorial work, and they are the reason to generate the file from data rather than to dump it.

Checking the result

curl -sI https://acme.example/llms.txt | grep -i -E 'HTTP/|content-type'
curl -s https://acme.example/llms.txt | head -8
curl -s https://acme.example/llms.txt | grep -c '^- \['

You want a 200, a text/ content type, an H1 on the first line, a blockquote that reads like a description, and a link count in the tens rather than the hundreds. Then run the site through a validator, which also catches relative links where an absolute URL was intended and a missing trailing newline.

Validate the generated file

Paste your site URL and the validator fetches /llms.txt, checks the structure against the spec, and lists every link it found so you can confirm the build produced what you expected.

Open the validator →

FAQ

Can I just put llms.txt in the public folder?

Yes, and for a small site that changes a few times a year it is the right answer: public/llms.txt in Next.js and Astro, static/llms.txt in Hugo, served as-is. Generate it only when pages are added often enough that a hand-written file would go stale, or when the same list already drives your sitemap and you do not want two copies.

Does Next.js have a built-in llms.txt convention like robots.ts and sitemap.ts?

No. As of September 2026 the App Router has metadata file conventions for robots.txt, sitemap.xml, icons, and Open Graph images, but not for llms.txt; a discussion on the Next.js repository asks for one. A Route Handler at app/llms.txt/route.ts does the job, and the Next.js documentation site itself serves its index at /docs/llms.txt.

Why does my Next.js llms.txt run on every request?

Because since Next.js 15 a GET Route Handler is dynamic by default. Add export const dynamic = 'force-static' to the route file and the response is rendered once at build time, which is what a file that only changes when you deploy needs.

In Astro, are the response headers I set in the endpoint used?

Only in server-rendered output. In a static build the endpoint runs once and its body is written to dist/llms.txt; the host then serves that file with a Content-Type chosen by the extension, which is usually text/plain. That is acceptable. If you need text/markdown, set it in your host's headers configuration rather than in the endpoint.

Which media type should the Hugo output format use?

text/plain, which Hugo ships with and which gives the file the .txt suffix. If you used text/markdown the generated file would be llms.md, because Hugo takes the suffix from the media type. The served Content-Type on a static host follows the extension anyway, so text/plain for a .txt file is what you get on Netlify or GitHub Pages regardless of what Hugo thinks.

Should the generated file list every page like the sitemap does?

No. The sitemap is for completeness; llms.txt is a curated index with a sentence per link. Reuse the same source list, then filter it: keep the sections you would show a new visitor, drop pagination, tag pages, and legal pages, and write a description for each entry. A file with 40 annotated links is more useful to an assistant than one with 400 bare ones.

Next steps