Implementing llms.txt in Next.js 15 with Sanity CMS

Step-by-step guide to integrate llms.txt support for your blogging ecosystem with Next.js 15 and Sanity CMS.

·Matija Žiberna·
Implementing llms.txt in Next.js 15 with Sanity CMS

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

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:

  1. Expose Markdown-ready posts from Sanity via the Next cache layer.
  2. Serve /blog/md/{slug} as static Markdown.
  3. Link the Markdown twin from the HTML article.
  4. Generate /llms.txt.
  5. 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.

// File: src/lib/sanity/queries/queries.ts
export const MARKDOWN_POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
  _id,
  title,
  subtitle,
  metaDescription,
  slug,
  publishedAt,
  dateModified,
  _updatedAt,
  excerpt,
  "hasMarkdown": defined(markdownContent) && markdownContent != "",
  "categories": categories[]->{
    title,
    slug
  },
  "primaryCategory": categories[0]->{
    title,
    slug
  }
}`)

Pulling only the fields llms.txt needs keeps the resulting payload under Next.js’ 2 MB cache limit.

// File: src/lib/sanity/post-cache.ts
export interface MarkdownPostSummary {
  _id: string
  title?: string | null
  subtitle?: string | null
  metaDescription?: string | null
  excerpt?: string | null
  slug?: { current?: string | null } | string | null
  publishedAt?: string | null
  dateModified?: string | null
  _updatedAt?: string | null
  hasMarkdown?: boolean | null
  categories?: Array<{
    title?: string | null
    slug?: {
      current?: string | null
    } | string | null
  }> | null
  primaryCategory?: {
    title?: string | null
    slug?: {
      current?: string | null
    } | string | null
  } | null
}

function resolveSlugField(
  slug: string | { current?: string | null } | null | undefined
): string | null {
  if (!slug) {
    return null
  }

  if (typeof slug === 'string') {
    const trimmed = slug.trim()
    return trimmed.length > 0 ? trimmed : null
  }

  if (typeof slug === 'object' && typeof slug.current === 'string') {
    const trimmed = slug.current.trim()
    return trimmed.length > 0 ? trimmed : null
  }

  return null
}

const fetchMarkdownPosts = unstable_cache(
  async (): Promise<MarkdownPostSummary[] | null> => {
    const posts = await client.fetch<MarkdownPostSummary[] | null>(MARKDOWN_POSTS_QUERY)

    if (!posts) {
      return null
    }

    const published = filterPublishedPosts(posts)

    return published.filter((post) => Boolean(resolveSlugField(post.slug) && post.hasMarkdown))
  },
  ['sanity-posts-markdown-v3'],
  {
    revalidate: REVALIDATE_SECONDS,
    tags: ['post'],
  }
)

export async function getMarkdownPosts(): Promise<MarkdownPostSummary[]> {
  const posts = await fetchMarkdownPosts()

  if (!posts) {
    return []
  }

  return posts.filter((post) => Boolean(post.hasMarkdown && resolveSlugField(post.slug)))
}

export async function getMarkdownPostSlugs(): Promise<string[]> {
  const posts = await getMarkdownPosts()

  return posts
    .map((post) => resolveSlugField(post.slug))
    .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0)
}

This gives the rest of the app a cheap way to list Markdown-ready articles without hammering Sanity.

2. Serve Markdown at /blog/md/[slug]

With the helpers in place, create a static route handler that emits Markdown, complete with metadata and a footer for categories and keywords.

// File: src/app/(non-intl)/blog/md/[slug]/route.ts
import type { POST_QUERYResult } from '@/../sanity.types'
import { getMarkdownPostSlugs, getPostBySlug } from '@/lib/sanity/post-cache'

export const dynamic = 'force-static'
export const revalidate = 3600

type RouteParams = { slug: string }

export async function generateStaticParams() {
  const slugs = await getMarkdownPostSlugs()
  return slugs.map((slug) => ({ slug }))
}

type MarkdownReadyPost = NonNullable<POST_QUERYResult> & { markdownContent: string }

function formatDate(input?: string | null) {
  if (!input) {
    return null
  }
  const parsed = new Date(input)
  if (Number.isNaN(parsed.getTime())) {
    return null
  }
  return parsed.toISOString().split('T')[0]
}

function buildMarkdownDocument(post: MarkdownReadyPost): string {
  const lines: string[] = []
  const title = post.title?.trim() || 'Untitled Post'
  lines.push(`# ${title}`)

  const description = post.metaDescription?.trim() || post.subtitle?.trim()
  if (description) {
    lines.push(`> ${description.replace(/\s+/g, ' ').trim()}`)
  }

  const metaParts: string[] = []
  const author = post.author?.name?.trim()
  const published = formatDate(post.publishedAt)
  if (author) metaParts.push(author)
  if (published) metaParts.push(published)
  if (metaParts.length) lines.push(metaParts.join(' · '))

  lines.push('')
  lines.push(post.markdownContent.trim())

  const categories = Array.isArray(post.categories)
    ? post.categories
        .map((category) => {
          if (!category) return null
          if (typeof category.title === 'string' && category.title.trim()) {
            return category.title.trim()
          }
          if (typeof category.slug === 'object' && category.slug?.current) {
            return category.slug.current
          }
          if (typeof category.slug === 'string' && category.slug.trim()) {
            return category.slug.trim()
          }
          return null
        })
        .filter(Boolean)
    : []

  const keywords = Array.isArray(post.keywords)
    ? post.keywords.filter((keyword): keyword is string => typeof keyword === 'string' && keyword.trim().length > 0)
    : []

  const footerSegments: string[] = []
  if (categories.length) footerSegments.push(`**Categories:** ${categories.join(', ')}`)
  if (keywords.length) footerSegments.push(`**Keywords:** ${keywords.join(', ')}`)

  const updated = formatDate(post.dateModified || post.publishedAt)
  if (updated) footerSegments.push(`**Last Updated:** ${updated}`)

  if (footerSegments.length) {
    lines.push('')
    lines.push('---')
    lines.push(footerSegments.join('  \n'))
  }

  return lines.join('\n')
}

export async function GET(
  _request: Request,
  { params }: { params: Promise<RouteParams> }
) {
  const { slug } = await params
  if (!slug) {
    return new Response('Not Found', { status: 404 })
  }

  const post = await getPostBySlug(slug)
  if (!post || typeof post.markdownContent !== 'string' || post.markdownContent.trim().length === 0) {
    return new Response('Not Found', { status: 404 })
  }

  const markdown = buildMarkdownDocument(post as MarkdownReadyPost)

  return new Response(markdown, {
    status: 200,
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Cache-Control': `public, max-age=0, s-maxage=${revalidate}`,
    },
  })
}

The handler stays static, capped by a one-hour revalidation window, and returns clean Markdown ready for LLM ingestion.

Expose the alternate format to both browsers and crawlers. First, add a text/markdown alternate in generateMetadata.

// File: src/app/(non-intl)/blog/[slug]/page.tsx
alternates: {
  canonical: `https://buildwithmatija.com/blog/${post.slug?.current || ''}`,
  ...(post.markdownContent && post.slug?.current
    ? {
        types: {
          'text/markdown': `https://buildwithmatija.com/blog/md/${post.slug.current}`,
        },
      }
    : {}),
},

Then drop a footer link at the bottom of the article so readers can grab the Markdown version.

// File: src/app/(non-intl)/blog/[slug]/page.tsx
{post.markdownContent && post.slug?.current ? (
  <div className="mt-12 border-t pt-6 text-sm">
    <a
      href={`/blog/md/${post.slug.current}`}
      className="inline-flex items-center gap-2 font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
    >
      <span aria-hidden>📄</span>
      <span>View markdown version</span>
    </a>
  </div>
) : null}

4. Generate /llms.txt

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.

// File: src/app/llms.txt/route.ts
const getRecentMarkdownPosts = cache(async () => {
  const posts = await getMarkdownPosts()
  return posts
    .filter((post) => post.hasMarkdown)
    .slice(0, 30)
    .map((post) => post as MarkdownPostEntry)
})

// …helpers trimmed for brevity…

export async function GET() {
  const posts = await getRecentMarkdownPosts()

  const grouped = new Map<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]) => {
      const lines: string[] = []
      lines.push(`### ${category}`)

      items
        .sort((a, b) => {
          const aDate = a.publishedAt ? new Date(a.publishedAt).getTime() : 0
          const bDate = b.publishedAt ? new Date(b.publishedAt).getTime() : 0
          return 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)

  const documentLines: 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')

  return new Response(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.

// File: src/app/sitemap.ts
const markdownPosts = await getMarkdownPosts()

const markdownPostUrls = markdownPosts
  .map((post) => {
    const slug = resolveSlug(post.slug as { current?: string | null } | string | null)
    if (!slug) {
      return null
    }

    const lastModifiedSource = post.dateModified || post._updatedAt || post.publishedAt
    const lastModified = lastModifiedSource ? new Date(lastModifiedSource) : new Date()

    return {
      url: `${baseUrl}/blog/md/${slug}`,
      lastModified,
      changeFrequency: 'monthly' as const,
      priority: 0.65 as const,
    }
  })
  .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))

return [
  {
    url: baseUrl,
    lastModified: new Date(),
    changeFrequency: 'daily',
    priority: 1,
  },
  // …existing entries…
  ...postUrls,
  ...markdownPostUrls,
  ...categoryUrls,
  ...commandUrls,
  {
    url: `${baseUrl}/llms.txt`,
    lastModified: new Date(),
    changeFrequency: 'weekly',
    priority: 0.9,
  },
]

Finally, allow the major LLM bots and reference llms.txt directly in robots.ts.

// File: src/app/robots.ts
const llmAgents = ['GPTBot', 'ClaudeBot', 'anthropic-ai', 'PerplexityBot', 'Googlebot']

export default function robots(): MetadataRoute.Robots {
  return {
    sitemap: `${baseUrl}/sitemap.xml`,
    rules: [
      {
        userAgent: '*',
        allow: ['/', '/llms.txt'],
        disallow: ['/studio', '/api/', '/wp-admin'],
      },
      ...llmAgents.map((userAgent) => ({
        userAgent,
        allow: ['/', '/llms.txt'],
      })),
    ],
  }
}

6. Verify the build

Finish by type-checking and manually curling your new routes.

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.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.