CoursesMarkdown Routes with Next.jsProduction-ready markdown routes
Markdown Routes with Next.js

Production-ready markdown routes

Configure caching, headers, analytics, performance, and error handling so your markdown routes run efficiently and reliably in production.
Log in to mark your progress for each Lesson and Task

Your markdown routes work locally — now let's make them production-ready.

  • Caching strategies for markdown routes
  • CDN configuration and the Vary: Accept header
  • Tracking markdown consumption in analytics
  • Performance optimization for large documents

Markdown routes should be cached aggressively — the content doesn't change frequently, and regenerating it on every request wastes compute.

Set appropriate headers in your Route Handlers:

return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
},
})

This means:

  • public — CDNs can cache this
  • max-age=60 — Fresh for 60 seconds
  • stale-while-revalidate=300 — Serve stale content for 5 minutes while revalidating
  • Articlemax-age=60, stale-while-revalidate=300 (content may update)
  • Sectionmax-age=300, stale-while-revalidate=600 (structure changes less often)
  • Sitemapmax-age=300, stale-while-revalidate=600 (structure changes less often)
// Sitemap - longer cache
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'
// Article - shorter cache
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'

When you use Accept header content negotiation, CDNs need to know that different Accept headers should get different cached responses.

The Vary header tells CDNs which request headers affect the response:

return new Response(markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Vary': 'Accept', // Different Accept = different cache entry
},
})

Vary: Accept reduces cache efficiency:

  • Without it: CDN caches one version per URL
  • With it: CDN caches multiple versions per URL (one per Accept value)

In practice, this isn't a big problem:

  • Most requests don't set Accept headers (browsers send */*)
  • AI agents consistently send text/markdown
  • You end up with ~2 cached versions per URL

Since we use explicit /markdown/ routes AND Accept header negotiation:

  • /docs/* URLs don't need Vary: Accept — the rewrite handles it
  • /markdown/* URLs always return markdown — no variation needed

The rewrite approach avoids the Vary header complexity entirely.

You want to know how much your markdown routes are used. Separate tracking for /markdown/* requests is straightforward.

Your analytics tool already tracks page views. The /markdown/* URLs appear as separate entries:

/docs/getting-started/quickstart → 1,234 views
/markdown/getting-started/quickstart → 89 views

For more detail, log custom events in your Route Handler:

export async function GET(request: NextRequest, { params }: ...) {
// ... generate markdown ...
// Log the request (fire and forget)
logMarkdownRequest({
path: request.nextUrl.pathname,
userAgent: request.headers.get('user-agent'),
acceptHeader: request.headers.get('accept'),
}).catch(() => {}) // Don't fail the request if logging fails
return new Response(markdown, { ... })
}

Track interesting properties:

  • User-Agent — Identify which AI tools are accessing your docs
  • Accept header — See if they used content negotiation or direct URLs
  • Path — Which articles are most requested

If you have articles with extensive content (long guides, API references), consider:

  1. Pagination — Split into multiple articles instead of one giant document
  2. Summary routes — Offer navigation-only versions for discovery
  3. Streaming — For very large responses, stream the markdown

Streaming example:

export async function GET() {
const sections = await client.fetch(SITEMAP_QUERY)
// Create a readable stream
const stream = new ReadableStream({
start(controller) {
controller.enqueue('# Sitemap\n\n')
for (const section of sections) {
controller.enqueue(`## ${section.title}\n\n`)
// ... more content
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
})
}

In practice, streaming is overkill for most documentation. An article would need to be massive (100KB+) before streaming provides meaningful benefit.

Ensure your GROQ queries only fetch what's needed:

// Good: Sitemap only needs navigation data
*[_type == "section"] {
title,
slug,
"articles": *[_type == "article" && section._ref == ^._id] {
title,
slug,
summary // No content field!
}
}
// Bad: Fetching content you don't need
*[_type == "section"] {
...,
"articles": *[...] {
...,
content // Unnecessary for sitemap!
}
}

Production routes need robust error handling:

export async function GET(request: NextRequest, { params }: ...) {
try {
// ... main logic ...
} catch (error) {
console.error('Markdown route error:', error)
// Return a helpful error (but not too detailed in production)
return new Response(
process.env.NODE_ENV === 'development'
? `Error: ${error instanceof Error ? error.message : 'Unknown'}`
: 'Internal server error',
{ status: 500 }
)
}
}

Log errors to your monitoring service (Sentry, LogRocket, etc.) for visibility.

Before deploying:

  • [ ] Environment variables set (NEXT_PUBLIC_SANITY_PROJECT_ID, etc.)
  • [ ] Cache-Control headers configured for each route
  • [ ] Error handling in place
  • [ ] Analytics tracking working (if desired)
  • [ ] Stega markers disabled for markdown routes
  • [ ] /sitemap.md returns expected content
  • [ ] Content negotiation works (curl -H "Accept: text/markdown")

Deploy a preview and test:

# Test sitemap
curl https://your-preview-url.vercel.app/sitemap.md
# Test content negotiation
curl -H "Accept: text/markdown" https://your-preview-url.vercel.app/docs/getting-started/quickstart
# Check headers
curl -I https://your-preview-url.vercel.app/markdown/getting-started/quickstart

You've built production-ready markdown routes. Let's verify your knowledge with a quick quiz.

Continue to Lesson 9: Course Quiz →

Mark lesson as complete
You have 1 uncompleted task in this lesson
0 of 1