Production-ready markdown routes
Your markdown routes work locally — now let's make them production-ready.
- Caching strategies for markdown routes
- CDN configuration and the
Vary: Acceptheader - 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 thismax-age=60— Fresh for 60 secondsstale-while-revalidate=300— Serve stale content for 5 minutes while revalidating
- Article —
max-age=60,stale-while-revalidate=300(content may update) - Section —
max-age=300,stale-while-revalidate=600(structure changes less often) - Sitemap —
max-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 needVary: 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 viewsFor 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:
- Pagination — Split into multiple articles instead of one giant document
- Summary routes — Offer navigation-only versions for discovery
- 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.mdreturns expected content - [ ] Content negotiation works (
curl -H "Accept: text/markdown")
Deploy a preview and test:
# Test sitemapcurl https://your-preview-url.vercel.app/sitemap.md
# Test content negotiationcurl -H "Accept: text/markdown" https://your-preview-url.vercel.app/docs/getting-started/quickstart
# Check headerscurl -I https://your-preview-url.vercel.app/markdown/getting-started/quickstartYou've built production-ready markdown routes. Let's verify your knowledge with a quick quiz.
Continue to Lesson 9: Course Quiz →