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).
- Query your CMS — fetch published pages with title, slug, and summary fields.
- Map to Markdown links — format each entry as
- [Title](https://url/): description. - Write or return the file — write to
public/llms.txtat 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: 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:
{
"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: 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 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 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 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-Controlheader 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 (
prebuildscript or CI step). - Validated with llmtxt.info/validator/ after each deploy.
Related guides
- How to create llms.txt — templates and deployment guide.
- Next.js guide — App Router and static generation.
- Astro guide — content collections and endpoints.
- llms.txt format reference — spec details.
- Validator · Generator.