/ llmtxt.info

llms.txt for headless CMS

Headless CMS platforms (Contentful, Sanity, Strapi, Directus) don't serve files directly — you generate llms.txt at build time from CMS data, or fetch it at request time via a server route.

Last updated:

The core pattern: CMS API → build step → static file

Headless CMS platforms manage content but do not serve arbitrary files at arbitrary paths. To publish llms.txt, you need to pull content from your CMS and write the file yourself — either at build time (static output) or at request time (server route).

  1. Query your CMS — fetch published pages with title, slug, and summary fields.
  2. Map to Markdown links — format each entry as - [Title](https://url/): description.
  3. Write or return the file — write to public/llms.txt at build time, or return from a server route at request time.

Contentful example

Use the Contentful JavaScript SDK to fetch entries via the Content Delivery API. Run this script during your build step (e.g., in package.json scripts or your CI pipeline) before the framework build:

scripts/generate-llms-txt.mjs (Contentful)
// scripts/generate-llms-txt.mjs
// Contentful: fetch published doc entries and generate llms.txt at build time

import { createClient } from 'contentful';
import { writeFileSync } from 'fs';

const client = createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
});

async function generateLlmsTxt() {
  // Fetch entries of content type 'docPage' sorted by display order
  const entries = await client.getEntries({
    content_type: 'docPage',
    order: 'fields.order',
    select: 'fields.title,fields.slug,fields.summary',
    limit: 100,
  });

  const SITE_URL = process.env.SITE_URL || 'https://yoursite.com';

  const links = entries.items
    .map((entry) => {
      const { title, slug, summary } = entry.fields;
      return `- [${title}](${SITE_URL}/docs/${slug}/): ${summary ?? ''}`;
    })
    .join('\n');

  const content = [
    '# Your Site',
    '',
    '> One-sentence description of your product.',
    '',
    '## Documentation',
    '',
    links,
    '',
    '## Optional',
    '',
    `- [Changelog](${SITE_URL}/changelog/): Release history.`,
  ].join('\n');

  writeFileSync('public/llms.txt', content, 'utf-8');
  console.log(`Generated llms.txt with ${entries.items.length} entries.`);
}

generateLlmsTxt().catch(console.error);

Add the script to your build pipeline:

package.json
{
  "scripts": {
    "prebuild": "node scripts/generate-llms-txt.mjs",
    "build": "next build"
  }
}

Sanity example

Use GROQ — Sanity’s query language — to fetch exactly the fields you need. The !(_id in path("drafts.**")) filter ensures only published documents are included:

scripts/generate-llms-txt.mjs (Sanity)
// scripts/generate-llms-txt.mjs
// Sanity: use GROQ to query published docs and generate llms.txt

import { createClient } from '@sanity/client';
import { writeFileSync } from 'fs';

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID,
  dataset: process.env.SANITY_DATASET || 'production',
  useCdn: false, // always fetch fresh data at build time
  apiVersion: '2024-01-01',
});

async function generateLlmsTxt() {
  // GROQ query: fetch all published doc entries with title, slug, and summary
  const docs = await client.fetch(
    `*[_type == "doc" && !(_id in path("drafts.**"))] | order(order asc) {
      title,
      "slug": slug.current,
      summary
    }`
  );

  const SITE_URL = process.env.SITE_URL || 'https://yoursite.com';

  const links = docs
    .map((doc) => `- [${doc.title}](${SITE_URL}/docs/${doc.slug}/): ${doc.summary ?? ''}`)
    .join('\n');

  const content = [
    '# Your Site',
    '',
    '> One-sentence description of your product.',
    '',
    '## Documentation',
    '',
    links,
  ].join('\n');

  writeFileSync('public/llms.txt', content, 'utf-8');
  console.log(`Generated llms.txt with ${docs.length} docs.`);
}

generateLlmsTxt().catch(console.error);

Strapi example

Strapi exposes a REST API at /api/:collection. Use the fields and filters query parameters to fetch only published documents with the fields you need:

scripts/generate-llms-txt.mjs (Strapi)
// scripts/generate-llms-txt.mjs
// Strapi v4/v5: fetch published articles via REST API and generate llms.txt

import { writeFileSync } from 'fs';

const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
const SITE_URL = process.env.SITE_URL || 'https://yoursite.com';

async function generateLlmsTxt() {
  // Fetch published docs — adjust the collection slug and fields as needed
  const res = await fetch(
    `${STRAPI_URL}/api/docs?fields[0]=title&fields[1]=slug&fields[2]=summary&filters[publishedAt][$notNull]=true&pagination[limit]=100`,
    {
      headers: STRAPI_TOKEN ? { Authorization: `Bearer ${STRAPI_TOKEN}` } : {},
    }
  );

  if (!res.ok) throw new Error(`Strapi API error: ${res.status}`);
  const { data } = await res.json();

  const links = data
    .map((item) => {
      const { title, slug, summary } = item.attributes ?? item; // v4 vs v5
      return `- [${title}](${SITE_URL}/docs/${slug}/): ${summary ?? ''}`;
    })
    .join('\n');

  const content = [
    '# Your Site',
    '',
    '> One-sentence description of your product.',
    '',
    '## Documentation',
    '',
    links,
  ].join('\n');

  writeFileSync('public/llms.txt', content, 'utf-8');
  console.log(`Generated llms.txt with ${data.length} entries.`);
}

generateLlmsTxt().catch(console.error);

Strapi v4 wraps response data in an attributes object; Strapi v5 returns fields at the top level. The example handles both with a fallback.

Integration with Next.js route handler

If you want the file to be always in sync without a full rebuild, use a Next.js App Router route handler with revalidate set to your desired TTL. Next.js will cache the response and regenerate it in the background:

app/llms.txt/route.ts (Next.js + Contentful)
// app/llms.txt/route.ts
// Next.js App Router — fetch from CMS at request time (or cache with revalidate)

import { NextResponse } from 'next/server';
import { createClient } from 'contentful';

// Cache for 1 hour (Next.js incremental static regeneration)
export const revalidate = 3600;

const client = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});

export async function GET() {
  const entries = await client.getEntries({
    content_type: 'docPage',
    order: 'fields.order',
    select: 'fields.title,fields.slug,fields.summary',
    limit: 100,
  });

  const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://yoursite.com';

  const links = entries.items
    .map((e: any) => `- [${e.fields.title}](${SITE_URL}/docs/${e.fields.slug}/): ${e.fields.summary ?? ''}`)
    .join('\n');

  const body = [
    '# Your Site',
    '',
    '> One-sentence description.',
    '',
    '## Documentation',
    '',
    links,
  ].join('\n');

  return new NextResponse(body, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
    },
  });
}

Integration with Astro endpoint

In Astro, create a src/pages/llms.txt.ts endpoint with export const prerender = true to generate the file at build time. Astro will call the CMS API during astro build and write the static file to dist/llms.txt:

src/pages/llms.txt.ts (Astro + Sanity)
// src/pages/llms.txt.ts
// Astro endpoint — fetch from CMS at build time (static generation)

import type { APIRoute } from 'astro';
import { createClient } from '@sanity/client';

// This endpoint is pre-rendered at build time
export const prerender = true;

const sanity = createClient({
  projectId: import.meta.env.SANITY_PROJECT_ID,
  dataset: import.meta.env.SANITY_DATASET || 'production',
  useCdn: false,
  apiVersion: '2024-01-01',
});

export const GET: APIRoute = async () => {
  const docs = await sanity.fetch(
    `*[_type == "doc" && !(_id in path("drafts.**"))] | order(order asc) {
      title, "slug": slug.current, summary
    }`
  );

  const SITE_URL = import.meta.env.SITE_URL || 'https://yoursite.com';

  const links = docs
    .map((doc: any) => `- [${doc.title}](${SITE_URL}/docs/${doc.slug}/): ${doc.summary ?? ''}`)
    .join('\n');

  const body = [
    '# Your Site',
    '',
    '> One-sentence description of your product.',
    '',
    '## Documentation',
    '',
    links,
  ].join('\n');

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

Static vs dynamic generation

  • Static (build time) — faster (CDN-cached), simpler, no runtime CMS dependency. Best when content changes infrequently or you deploy on every content change.
  • Dynamic (server route) — always reflects the latest CMS content. Best when content changes frequently between deployments, or when you can’t trigger a rebuild on content change. Add a Cache-Control header to avoid hammering the CMS API on every request.

Checklist

  • Script or endpoint fetches only published content (not drafts).
  • All generated URLs are absolute (https://).
  • File starts with exactly one H1.
  • Blockquote summary immediately follows the H1.
  • Each section has at least one link.
  • File is under 20 KB (curate — don’t dump every entry).
  • Build step runs before the framework build (prebuild script or CI step).
  • Validated with llmtxt.info/validator/ after each deploy.

Related guides

Sources