Content Negotiation with Rewrites
- Configure
next.config.tsrewrites for.mdsuffix URLs - Add
Acceptheader 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:
- Explicit
.mdURL → markdown Accept: text/markdownheader → markdown- 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:
- Two for explicit
.mdsuffix URLs (section and article) - Two for
Acceptheader 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/markdownAccept: text/markdown, text/htmlAccept: */*, 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.mdShould return markdown with Content-Type: text/markdown.
curl -H "Accept: text/markdown" http://localhost:3000/docs/guides/setupShould return the same markdown.
curl http://localhost:3000/docs/guides/setupShould return HTML with Content-Type: text/html.
If rewrites aren't triggering:
- Restart the dev server after changing
next.config.ts - Check that
rewritesis an async function returning an object - Verify the
sourcepattern matches your URL structure
If you're getting HTML when you expect markdown:
- Check the
Acceptheader is being sent correctly - Verify the
hascondition regex matches your header value - Ensure your
/md/...route handler setsContent-Type: text/markdown
If you're getting 404s:
- Verify your
/md/...route handlers exist - Check that the
destinationpath 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
beforeFilestiming - Configured
hasconditions forAcceptheader - Tested with
curlfor all three access patterns - Verified
Content-Typeheaders are correct - Disabled Stega markers for clean markdown output