Next.js
Middleware-driven negotiation for Next.js App Router, with proper Accept parsing, Vary, 406, and deploy-target notes for Vercel, Cloudflare, and self-hosted Node.
Next.js can content-negotiate at the middleware layer (all requests) or per-route (App Router route handlers). For most sites, middleware is cleaner — one place, all pages.
Middleware: parse Accept, rewrite Markdown requests
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const PRODUCES = ['text/html', 'text/markdown'];
type AcceptEntry = { type: string; q: number; specificity: number };
function parseAccept(header: string): AcceptEntry[] {
return header
.split(',')
.map((raw) => {
const parts = raw.trim().split(';').map((s) => s.trim());
const type = parts[0].toLowerCase();
let q = 1;
for (const param of parts.slice(1)) {
const [name, value] = param.split('=').map((s) => s.trim());
if (name === 'q') {
const parsed = Number(value);
if (!Number.isNaN(parsed)) q = Math.max(0, Math.min(1, parsed));
}
}
const specificity = type === '*/*' ? 0 : type.endsWith('/*') ? 1 : 2;
return { type, q, specificity };
});
// Preserve client order — position is used for tiebreaks below.
}
function matches(entry: AcceptEntry, candidate: string): boolean {
if (entry.type === '*/*') return true;
if (entry.type.endsWith('/*')) return candidate.startsWith(entry.type.slice(0, -1));
return entry.type === candidate;
}
function preferredType(header: string | null): string | null {
if (!header) return PRODUCES[0];
const entries = parseAccept(header);
if (entries.length === 0) return PRODUCES[0];
let bestType: string | null = null;
let bestQ = -1;
let bestPosition = Infinity;
for (const candidate of PRODUCES) {
// For each candidate, find the *most specific* matching range.
// RFC 9110 §12.5.1: specific ranges override less specific ones
// regardless of q — so `text/html;q=0, */*;q=1` correctly rejects
// text/html instead of letting the wildcard override.
let matched: AcceptEntry | null = null;
let matchedPosition = Infinity;
for (let idx = 0; idx < entries.length; idx++) {
const e = entries[idx];
if (!matches(e, candidate)) continue;
if (
matched === null ||
e.specificity > matched.specificity ||
(e.specificity === matched.specificity && idx < matchedPosition)
) {
matched = e;
matchedPosition = idx;
}
}
if (matched === null) continue;
const matchedQ: number = matched.q;
if (matchedQ <= 0) continue; // explicit rejection
// Across candidates: highest q wins; tie-break on client order
// so `Accept: text/markdown, text/html, */*` picks text/markdown.
if (matchedQ > bestQ || (matchedQ === bestQ && matchedPosition < bestPosition)) {
bestQ = matchedQ;
bestPosition = matchedPosition;
bestType = candidate;
}
}
return bestType;
}
function appendVaryAccept(headers: Headers): void {
const existing = headers.get('Vary');
if (!existing) {
headers.set('Vary', 'Accept');
return;
}
const tokens = existing.split(',').map((s) => s.trim().toLowerCase());
if (!tokens.includes('accept')) {
headers.set('Vary', `${existing}, Accept`);
}
}
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// Explicit .md URL: always Markdown, regardless of Accept. This is
// the path that Link: rel="alternate" points at — crawlers that
// follow it may not send an Accept header at all. Rewrite to the
// route handler with the `.md` stripped so one handler covers both
// the canonical URL and its sibling.
if (pathname.endsWith('.md')) {
const url = req.nextUrl.clone();
url.pathname = `/api/markdown${pathname.slice(0, -3)}`;
const rewritten = NextResponse.rewrite(url);
appendVaryAccept(rewritten.headers);
return rewritten;
}
const acceptHeader = req.headers.get('accept');
const chosen = preferredType(acceptHeader);
if (chosen === 'text/markdown') {
const url = req.nextUrl.clone();
url.pathname = `/api/markdown${pathname}`;
const rewritten = NextResponse.rewrite(url);
appendVaryAccept(rewritten.headers);
return rewritten;
}
if (chosen === null && acceptHeader) {
return new Response(
'Not Acceptable\n\nAvailable: text/html, text/markdown\n',
{
status: 406,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Vary': 'Accept',
},
},
);
}
const res = NextResponse.next();
appendVaryAccept(res.headers);
return res;
}
export const config = {
// Run on everything except Next internals and API routes.
matcher: ['/((?!api/|_next/|_vercel/).*)'],
};
That gives you proper q-value handling, right-specificity tiebreak,
Vary: Accept on both branches, and a spec-correct 406 when the
client rejects everything you produce.
Route handler: serve the Markdown representation
// app/api/markdown/[[...slug]]/route.ts
import { readFile } from 'node:fs/promises';
import path from 'node:path';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug?: string[] }> },
) {
const { slug = [] } = await params;
const contentPath = path.join(
process.cwd(),
'content',
...slug,
) + '.md';
let body: string;
try {
body = await readFile(contentPath, 'utf8');
} catch {
return new Response('Not found', { status: 404 });
}
return new Response(body, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Vary': 'Accept',
'Cache-Control': 's-maxage=60, stale-while-revalidate=86400',
},
});
}
Swap the readFile for a DB/CMS fetch if that’s your source of truth.
If your content is authored as HTML and you need Markdown on request,
use turndown to convert on
the fly and cache the result.
Per-route (no middleware)
If middleware is off the table, check Accept inside a route handler:
// app/blog/[slug]/route.ts
import { preferredType } from '@/lib/accept';
import { getPost } from '@/lib/posts';
export async function GET(
req: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return new Response('Not found', { status: 404 });
const chosen = preferredType(req.headers.get('accept'));
if (chosen === 'text/markdown') {
return new Response(post.markdown, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Vary': 'Accept',
},
});
}
// Fall through to HTML rendering (or render HTML from post.html here)
}
You can only do this on route handlers — Server Component pages render HTML unconditionally, so middleware is the only option there.
Alternative: next.config.ts rewrites (no runtime compute)
If you don’t need proper q-value parsing, you can negotiate entirely in
static config via rewrites.beforeFiles. No middleware runs, no
compute per request — forward-compatible with Next’s ongoing
middleware-to-proxy rename.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
rewrites: async () => ({
beforeFiles: [
{
// `:slug*` matches nested paths too (/docs/guides/intro, etc.)
source: '/docs/:slug*',
// Destination must match an actual route in your app.
destination: '/api/markdown/:slug*',
has: [
{
type: 'header',
key: 'accept',
// Match Accept values that prefer text/markdown or text/plain
// over text/html (positional, not q-value aware).
value:
'(?=.*(?:text/plain|text/markdown))(?!.*text/html.*(?:text/plain|text/markdown)).*',
},
],
},
],
}),
// Vercel only bundles files Next.js can trace. If your markdown lives
// outside the app/ tree, tell the tracer about it:
outputFileTracingIncludes: {
'*': ['./content/**/*.md'],
},
};
export default nextConfig;
Tradeoffs vs. the middleware approach:
- Zero runtime compute, fully static config
- Forward-compatible with Next’s proxy rename
- Doesn’t honor
q=0rejections (a client explicitly refusing Markdown still gets rewritten) - Doesn’t rank by q-value — a higher-q
text/htmlloses to a lower-qtext/markdownas long as Markdown appears first in the header - Can’t emit
Vary: Acceptfrom rewrites alone; set it in the route handler’s response
This is the approach the Vercel Labs markdown-to-agents template uses. Good for simple sites; use middleware when you need correctness.
Hosting-specific notes
Vercel
Cache-Control: s-maxage=Ntriggers the edge cache, which honorsVary: Accept.- Both representations cache independently; warm both after deploy.
- Check
x-vercel-cache: HITon responses to confirm caching. - Bundle source
.mdfiles viaoutputFileTracingIncludesinnext.config.ts(see the rewrites section above). Without it, your route handler can read the files innpm run devbut throwsENOENTin production because Next’s file tracer didn’t include them in the deployment.
Cloudflare Pages / Workers
- Cloudflare’s edge cache honors
Vary: Acceptwith Cache Rules. - Or enable Markdown for Agents at the edge and skip origin negotiation entirely.
Self-hosted Node
- Next.js middleware runs in the Node server. Your reverse proxy
(Nginx, Caddy) should forward the
Acceptheader through and not strip theVaryheader on the way back.
Netlify
- Same pattern as Vercel —
Vary: Acceptin the response,Cache-Control: s-maxage=Nto trigger edge caching.
Verify
curl -sI -H "Accept: text/markdown" https://yoursite.com/blog/hello
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
curl -sI -H "Accept: application/pdf" https://yoursite.com/blog/hello
# HTTP/1.1 406 Not Acceptable
External resources
- Markdown to Agents, HTML to Humans — Vercel template with one-click deploy.