CoursesMarkdown Routes with Next.jsContent Negotiation with Rewrites
Markdown Routes with Next.js

Content Negotiation with Rewrites

Configure Next.js rewrites to serve markdown via .md suffix URLs and Accept header negotiation, keeping the same base URL for HTML and markdown.
Log in to mark your progress for each Lesson and Task
  • Configure next.config.ts rewrites for .md suffix URLs
  • Add Accept header content negotiation
  • Keep the same base URL for both HTML and markdown responses
  • Test both access patterns with curl

You want the same /docs URL to serve three different ways:

  1. Explicit .md URL → markdown
  2. Accept: text/markdown header → markdown
  3. Default (no suffix, no header) → HTML

This keeps your documentation URLs clean while supporting both human browsers and programmatic access.

You need four rewrite rules in next.config.ts:

  1. Two for explicit .md suffix URLs (section and article)
  2. Two for Accept header negotiation (section and article)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
rewrites: async () => ({
beforeFiles: [
// Explicit .md URL access (no header required)
{
source: "/docs/:section/:article.md",
destination: "/md/:section/:article",
},
{
source: "/docs/:section.md",
destination: "/md/:section",
},
// Content negotiation (Accept header)
{
source: "/docs/:section/:article",
destination: "/md/:section/:article",
has: [
{
type: "header",
key: "accept",
value: "(.*)text/markdown(.*)",
},
],
},
{
source: "/docs/:section",
destination: "/md/:section",
has: [
{
type: "header",
key: "accept",
value: "(.*)text/markdown(.*)",
},
],
},
],
}),
};
export default nextConfig;

When someone requests /docs/guides/setup.md, Next.js rewrites it to /md/guides/setup. The browser URL stays /docs/guides/setup.md, but your route handler at app/md/[section]/[article]/route.ts serves the response.

When someone requests /docs/guides/setup with Accept: text/markdown, the has condition matches and Next.js rewrites to /md/guides/setup. Same route handler, same markdown response.

When someone requests /docs/guides/setup without the .md suffix and without the Accept: text/markdown header, no rewrite happens. Next.js serves the normal page from app/docs/[section]/[article]/page.tsx.

The has array checks the Accept header:

has: [
{
type: "header",
key: "accept",
value: "(.*)text/markdown(.*)",
},
]

The regex (.*)text/markdown(.*) matches any Accept header containing text/markdown, including:

  • Accept: text/markdown
  • Accept: text/markdown, text/html
  • Accept: */*, text/markdown

The beforeFiles timing ensures rewrites happen before Next.js checks for matching pages. This means:

  • Requests matching the rewrite rules go to /md/... routes
  • Requests not matching fall through to /docs/... pages
  • No conflicts between routes and rewrites

Test all three access patterns with curl:

curl http://localhost:3000/docs/guides/setup.md

Should return markdown with Content-Type: text/markdown.

curl -H "Accept: text/markdown" http://localhost:3000/docs/guides/setup

Should return the same markdown.

curl http://localhost:3000/docs/guides/setup

Should return HTML with Content-Type: text/html.

If rewrites aren't triggering:

  • Restart the dev server after changing next.config.ts
  • Check that rewrites is an async function returning an object
  • Verify the source pattern matches your URL structure

If you're getting HTML when you expect markdown:

  • Check the Accept header is being sent correctly
  • Verify the has condition regex matches your header value
  • Ensure your /md/... route handler sets Content-Type: text/markdown

If you're getting 404s:

  • Verify your /md/... route handlers exist
  • Check that the destination path matches your route structure
  • Ensure parameter names (:section, :article) match between source and destination

If you're using Sanity with visual editing, disable Stega markers in your markdown route handlers to avoid hidden characters in the output:

const lesson = await client.fetch(
query,
params,
{
stega: false, // Disable visual editing markers
}
);

This ensures clean markdown output without invisible editing metadata.

  • Added four rewrite rules to next.config.ts
  • Used beforeFiles timing
  • Configured has conditions for Accept header
  • Tested with curl for all three access patterns
  • Verified Content-Type headers are correct
  • Disabled Stega markers for clean markdown output
Mark lesson as complete
You have 1 uncompleted task in this lesson
0 of 1