CoursesMarkdown Routes with Next.jsBuilding the Markdown Route Handler
Markdown Routes with Next.js

Building the Markdown Route Handler

Time to serve our first markdown — Route Handlers that return docs articles as markdown.
Log in to mark your progress for each Lesson and Task
  • Create Route Handlers for markdown responses
  • Understand the .md suffix 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 Handler
  • next.config.ts rewrites — map .md URLs 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
Mark lesson as complete
You have 1 uncompleted task in this lesson
0 of 1