In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Implementing llms.txt in Next.js 15 with Sanity CMS
Last month I needed to ship llms.txt support for a Next.js 15 blog backed by Sanity. The goal was simple: every post should have a Markdown twin, and llms.txt should list those twins so LLM crawlers could digest them without scraping HTML. This guide walks through the exact implementation I put into production.
I’ll assume you already have a Next.js App Router project connected to Sanity, and that blog posts store Markdown in markdownContent. We’re going to:
Expose Markdown-ready posts from Sanity via the Next cache layer.
Serve /blog/md/{slug} as static Markdown.
Link the Markdown twin from the HTML article.
Generate /llms.txt.
Expand the sitemap and robots directives.
1. Cache Markdown-capable posts
Next.js ships with unstable_cache for memoising expensive fetches. Start by creating a dedicated Sanity query and cache helper that flags posts with populated Markdown.
With Markdown posts available, build the llms.txt route by grouping posts into categories and listing the top 30 entries. Each item links directly to the Markdown twin.
typescript
// File: src/app/llms.txt/route.tsconst getRecentMarkdownPosts = cache(async () => {
const posts = awaitgetMarkdownPosts()
return posts
.filter((post) => post.hasMarkdown)
.slice(0, 30)
.map((post) => post asMarkdownPostEntry)
})
// …helpers trimmed for brevity…exportasyncfunctionGET() {
const posts = awaitgetRecentMarkdownPosts()
const grouped = newMap<string, MarkdownPostEntry[]>()
posts.forEach((post) => {
const categoryTitle = normalizeCategoryTitle(post)
const list = grouped.get(categoryTitle) ?? []
list.push(post)
grouped.set(categoryTitle, list)
})
const categorySections = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
.map(([category, items]) => {
constlines: string[] = []
lines.push(`### ${category}`)
items
.sort((a, b) => {
const aDate = a.publishedAt ? newDate(a.publishedAt).getTime() : 0const bDate = b.publishedAt ? newDate(b.publishedAt).getTime() : 0return bDate - aDate
})
.forEach((post) => {
const slug = resolveSlugValue(post.slug)
if (!slug) {
return
}
const url = `${siteOrigin}/blog/md/${slug}`const title = post.title?.trim() || 'Untitled Post'const date = formatDate(post.publishedAt)
const summary = summarizePost(post)
const dateSuffix = date ? ` (${date})` : ''
lines.push(`- [${title}](${url})${dateSuffix}: ${summary}`)
})
lines.push('')
return lines.join('\n')
})
.filter(Boolean)
constdocumentLines: string[] = []
documentLines.push(`# ${siteTitle}`)
documentLines.push(`> ${siteDescription}`)
documentLines.push('')
documentLines.push('## About Matija Ziberna')
documentLines.push('Matija Ziberna is a full-stack developer and technical founder partnering with teams to ship production-ready products, automation, and AI-enabled workflows.')
documentLines.push('')
documentLines.push('## Services')
services.forEach((service) => {
documentLines.push(`- [${service.title}](${service.url}): ${service.blurb}`)
})
documentLines.push('')
documentLines.push('## Markdown Content Index')
documentLines.push('All blog posts listed below have dedicated Markdown versions designed for LLM consumption. Use the category sections to find content relevant to your task.')
documentLines.push('')
documentLines.push('## Recent Posts by Category')
if (categorySections.length === 0) {
documentLines.push('- No markdown posts found. Add markdownContent in Sanity to populate this section.')
} else {
documentLines.push(...categorySections)
}
documentLines.push('')
documentLines.push('## Discovery Notes')
documentLines.push(`- Sitemap: ${siteOrigin}/sitemap.xml`)
documentLines.push(`- Markdown endpoints follow the pattern ${siteOrigin}/blog/md/{slug}`)
const body = documentLines.join('\n')
returnnewResponse(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': `public, max-age=0, s-maxage=${revalidate}`,
},
})
}
5. Expand sitemap and robots directives
Surface the Markdown endpoints in the sitemap alongside the canonical HTML pages and the llms.txt manifest.
Finish by type-checking and manually curling your new routes.
bash
npx tsc --noEmit
If /blog/md/{slug} and /llms.txt both respond with static content, you’re done. You now have Markdown mirrors of every post, llms.txt points to them, and crawlers can discover the alternate format through the sitemap and robots directives.
Let me know in the comments if you have questions, and subscribe for more practical development guides.