← Blog

Generate your sitemap and llms.txt from one source in Next.js, so neither goes stale

One posts array feeds the blog, sitemap.xml, robots.txt, and llms.txt, so publishing a post updates every discovery file and none of them can go stale.

Discovery files rot quietly. You publish a post, forget to add it to the hand-maintained sitemap, and search engines take weeks longer to find it. You change a product claim on the landing page and the summary you wrote for AI crawlers keeps saying the old thing. Nothing errors; the files are just wrong. The fix is boring and effective: generate every discovery file from the same data the pages are built from, so publishing a post updates all of them in the same build.

One array, every consumer

In the kit, blog posts are a typed array in one file. The blog index renders from it, each post page is statically generated from it, and so are the sitemap and the llms.txt file. Adding an entry is the whole publishing step — there is no second list to update. Here is the sitemap, which Next.js turns into /sitemap.xml:

export default function sitemap(): MetadataRoute.Sitemap {
  const staticRoutes = ["", "/pricing", "/chat", "/blog", "/terms", "/privacy"].map(
    (path) => ({
      url: `${BASE}${path}`,
      changeFrequency: "weekly" as const,
      priority: path === "" ? 1 : 0.7,
    }),
  );

  const blogRoutes = posts.map((post) => ({
    url: `${BASE}/blog/${post.slug}`,
    lastModified: post.date,
    changeFrequency: "monthly" as const,
    priority: 0.6,
  }));

  return [...staticRoutes, ...blogRoutes];
}

The Next.js docs describe sitemap.ts as a special route handler that is cached by default unless it uses a request-time API, so this costs nothing per request — it is computed at build time, from the same posts the pages use. A matching robots.ts points crawlers at it and keeps the signed-in dashboard and API routes out of the index.

llms.txt, from the same array

llms.txt is a proposal from llmstxt.org for a Markdown file at your site root that tells AI tools what the site is: an H1 with the name, a blockquote summary, then H2 sections of links, with an "Optional" section for the skippable extras. It is a route handler like any other, forced static and built from the same data:

export const dynamic = "force-static";

export function GET() {
  const blogLinks = [...posts]
    .sort((a, b) => (a.date < b.date ? 1 : -1))
    .map((p) => `- [${p.title}](${BASE}/blog/${p.slug}): ${p.description}`)
    .join("\n");

  const body = `# Shipwright\n\n> Shipwright is a Next.js 16 starter kit ...\n\n## Blog\n\n${blogLinks}\n`;
  return new Response(body, {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}

Each post contributes its title and its meta description, so the description you already wrote for search results doubles as the summary AI tools see. Publish a post and its line appears on the next build.

The part that still rots: hand-written prose

Generating the lists solves half the problem. The summary paragraph at the top of llms.txt is still prose someone wrote, and prose drifts from code. This happened here: the summary said the kit's eval harness "fails CI on quality regressions," while the CI workflow deliberately does not run evals. The generated blog list was correct; the hand-written claim was not, and it sat in a file whose whole purpose is to be quoted by machines. Treat that paragraph like code: review it when the product changes, and keep claims to things you can point at in the repo.

Things that bite

  • A sitemap is an invitation, not a guarantee. Generating /sitemap.xml does not make anyone read it. Submit it in Google Search Console and check the coverage report — that is how you learn whether pages are actually being indexed.
  • Use real dates for lastModified. The Next.js docs' example uses new Date(), which tells crawlers every page changed on every deploy. Use the post's own date (or a real updated-at) so the field means something.
  • llms.txt is a proposal, not a standard. Some AI tools read it and many do not. It is cheap to generate, so ship it — but do not expect it to do the work of good pages and real links.
  • One canonical host. Build every URL from one base constant (here, the www origin) so the sitemap, robots, canonical tags, and llms.txt never disagree about which host is real.

Shipwright — the Next.js and Claude starter kit this blog documents — generates its sitemap, robots.txt, and llms.txt from code like this, so the discovery files update every time a post ships. See the generated llms.txt, or start from the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →