Building the Markdown Route Handler
- Create Route Handlers for markdown responses
- Understand the
.mdsuffix URL pattern - Fetch content from Sanity and convert to markdown
- Return a Response with correct Content-Type header
- Handle 404s gracefully
We're implementing the .md suffix pattern:
/docs/getting-started/quickstart→ HTML article/docs/getting-started/quickstart.md→ Markdown/docs/getting-started.md→ Section listing (markdown)
The .md suffix URLs are rewritten to internal /md/ Route Handlers:
/docs/section/article.md → /md/section/article (rewrite)/docs/section.md → /md/section (rewrite)Create the route directories:
mkdir -p src/app/md/[section]/[article]mkdir -p src/app/md/[section]import { NextRequest } from 'next/server'import { client } from '@/sanity/client'import { ARTICLE_WITH_NAV_QUERY } from '@/sanity/queries'import { buildArticleMarkdown } from '@/lib/markdownSerializers'
/** * Route Handler for markdown article responses. * * Internal route: /md/[section]/[article] * Accessed via: /docs/[section]/[article].md (rewrite) * Or via content negotiation: /docs/[section]/[article] with Accept: text/markdown */export async function GET( request: NextRequest, { params }: { params: Promise<{ section: string; article: string }> }) { const { section: sectionSlug, article: articleSlug } = await params try { const data = await client.fetch(ARTICLE_WITH_NAV_QUERY, { sectionSlug, articleSlug, }) if (!data.article) { return new Response('Article not found', { status: 404 }) } // Find prev/next articles for navigation const articleIndex = data.allArticles.findIndex( (a: { slug: { current: string } }) => a.slug.current === articleSlug ) const prevArticle = articleIndex > 0 ? data.allArticles[articleIndex - 1] : undefined const nextArticle = articleIndex < data.allArticles.length - 1 ? data.allArticles[articleIndex + 1] : undefined const canonicalUrl = `${process.env.NEXT_PUBLIC_SITE_URL || ''}/docs/${sectionSlug}/${articleSlug}` const markdown = buildArticleMarkdown(data.article, { prevArticle, nextArticle, canonicalUrl, }) return new Response(markdown, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300', }, }) } catch (error) { console.error('Markdown route error:', error) return new Response('Internal server error', { status: 500 }) }}import { NextRequest } from 'next/server'import { client } from '@/sanity/client'import { SECTION_QUERY } from '@/sanity/queries'import { buildSectionMarkdown } from '@/lib/markdownSerializers'
/** * Route Handler for markdown section responses. * * Internal route: /md/[section] * Accessed via: /docs/[section].md (rewrite) * Or via content negotiation: /docs/[section] with Accept: text/markdown */export async function GET( request: NextRequest, { params }: { params: Promise<{ section: string }> }) { const { section: sectionSlug } = await params try { const section = await client.fetch(SECTION_QUERY, { sectionSlug }) if (!section) { return new Response('Section not found', { status: 404 }) } const markdown = buildSectionMarkdown(section) return new Response(markdown, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', }, }) } catch (error) { console.error('Section markdown route error:', error) return new Response('Internal server error', { status: 500 }) }}Next.js doesn't support dynamic segments with file extensions like [article].md. So we use:
/md/[section]/[article]/route.ts— internal Route Handlernext.config.tsrewrites — map.mdURLs to/md/routes
This keeps the public URLs clean (/docs/section/article.md) while using valid Next.js routing internally.
In Next.js 15+, route params are a Promise that must be awaited:
export async function GET( request: NextRequest, { params }: { params: Promise<{ section: string; article: string }> }) { const { section, article } = await params // ...}Route Handlers return raw Response objects, not JSX:
return new Response(markdown, { headers: { 'Content-Type': 'text/markdown; charset=utf-8', },})The Content-Type: text/markdown header tells clients this is markdown, not HTML.
The article handler uses a query that includes navigation context:
export const ARTICLE_WITH_NAV_QUERY = groq` { "article": *[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] { _id, title, slug, summary, content, "section": section-> { _id, title, slug } }, "allArticles": *[_type == "article" && section->slug.current == $sectionSlug] | order(order asc) { _id, title, slug, order } }`This fetches both the article and all sibling articles, so we can calculate prev/next links.
The Route Handlers exist, but they need rewrites to be accessible via .md URLs. We'll configure those in the next lesson.
For now, test the internal routes directly:
npm run dev# Direct internal route access (will work after rewrites)curl http://localhost:3000/md/getting-started/quickstart- Route Handler files created in
/md/[section]/and/md/[section]/[article]/ - Build passes (
npm run build) - Route Handlers appear in build output