PATTERN Cited by 1 source
Accept-header rewrite to markdown route¶
Pattern¶
On every request, check if Accept contains text/markdown;
if so, rewrite the URL to a dedicated markdown-serving route
before any static-file or page handler runs. The user-facing
URL is unchanged from the client's perspective; the server
internally routes header-matching requests to a separate
handler that produces markdown from the same underlying
content source.
This is the Vercel-on-Next.js
implementation of
markdown content
negotiation, contrasted with Cloudflare's
Transform-Rules-based
/index.md fallback (a URL-suffix convention enforced at
the edge).
Vercel / Next.js implementation shape¶
Step 1 — next.config.ts rewrite rule with has header
matcher:
import type { NextConfig } from 'next';
function markdownRewrite(prefix: string) {
return {
source: `${prefix}/:path*`,
has: [
{ type: 'header', key: 'accept', value: '(.*)text/markdown(.*)' },
],
destination: `${prefix}/md/:path*`,
};
}
const nextConfig: NextConfig = {
async rewrites() {
return {
beforeFiles: [markdownRewrite('/blog'), markdownRewrite('/changelog')],
};
},
};
export default nextConfig;
Key details:
beforeFiles— runs before static file serving, so.md-suffixed physical files (if any) don't short-circuit the rewrite.- Regex
(.*)text/markdown(.*)— permissive match that accepts anyAcceptheader containingtext/markdownanywhere, includingAccept: text/markdown, text/html, */*(client preference lists). - Destination is
${prefix}/md/:path*— internal URL convention. The/md/segment is never user-visible; the rewrite is opaque to the client. - Per-prefix scoping — the rewrite applies only to
/blogand/changelog. Other paths bypass markdown routing entirely.
Step 2 — dedicated route handler at app/blog/md/[...slug]/route.ts:
import { notFound } from 'next/navigation';
import { getMarkdownContent } from '@/lib/content';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug?: string[] }> }
) {
const { slug } = await params;
const content = getMarkdownContent(slug?.join('/') ?? 'index');
if (!content) notFound();
return new Response(content, {
headers: { 'Content-Type': 'text/markdown' },
});
}
getMarkdownContent() is the project-specific converter —
for Vercel's CMS-hosted blog content, it walks the CMS rich-
text AST and emits markdown. For projects whose content is
already authored in markdown, this becomes a straight file
read.
Why this shape¶
- No content duplication. HTML and markdown are
generated from the same CMS source at request time; no
.mdfiles on disk; no build-step synchronisation burden. - Single URL. The agent and the browser share the same
URL; only the
Acceptheader differs. This is the architectural-composability argument Vercel makes: "content negotiation requires no site-specific knowledge. Any agent that sends the right header gets markdown automatically, from any site that supports it." URL-suffix conventions like/index.mdrequire per-site agent configuration; Accept-header negotiation does not. - Cache-friendly under the hood. Because the rewrite
routes to a different internal URL (
/blog/md/<slug>), cache keys are naturally distinct — noVary: Acceptneeded. HTML responses cache under/blog/<slug>; markdown responses cache under/blog/md/<slug>. - Composable with Next.js caching.
use cache/ ISR on the markdown route handler gives edge-cached markdown responses without extra work. - Testable. An engineer can test the markdown response
directly by
curling either the public URL with the header (curl https://vercel.com/blog/<slug> -H "Accept: text/markdown") or the internal route (curl https://vercel.com/blog/md/<slug>).
Contrast with Cloudflare Transform Rules¶
The Cloudflare pattern (patterns/dynamic-index-md-fallback) implements the same underlying concept with different primitives:
| Aspect | Vercel / Next.js | Cloudflare |
|---|---|---|
| Discovery | Accept header |
URL suffix (/index.md) |
| Rewrite locus | App-server (next.config.ts) |
Edge (Transform Rules) |
| Content conversion | Route handler (CMS → markdown) | Origin server (same) |
| Fallback if no header | No markdown served | /index.md URL serves markdown always |
| Agent configuration | Must send header | Must know URL suffix convention |
The two patterns are complementary — a site can implement both, giving agents that send the header the clean path and agents that don't the URL-suffix path. Vercel's post adds a third layer (patterns/link-rel-alternate-markdown-discovery) for agents that fetch HTML without the header.
Portability beyond Next.js¶
The rewrite-rule shape generalises to any HTTP stack with header-conditional URL rewriting:
- Nginx:
location ~ ^/blog/ { if ($http_accept ~ "text/markdown") { rewrite ^/blog/(.*)$ /blog/md/$1 last; } }— similar semantics, uglyifusage but functional. - Caddy:
rewritedirective with@mdheader header Accept *text/markdown*. - Express.js middleware:
app.use('/blog', (req, res, next) => { if (/text\/markdown/.test(req.headers.accept)) req.url = '/md' + req.url; next(); }). - AWS CloudFront + Lambda@Edge: viewer-request Lambda
checks
Acceptand rewrites the request URI. - Fastly VCL:
if (req.http.Accept ~ "text/markdown") { set req.url = "/md" + req.url; }.
The primitive is a header-conditional URL rewrite + a separate content-converting handler. Every major HTTP stack supports both.
Alternatives and trade-offs¶
Serve .md twins as static files. Build step generates
post.md next to post.html for every post; server serves
the right one based on Accept. Works, but doubles the build
output and requires content pipelines to keep both variants
in sync.
URL-suffix fallback (/index.md). Different primitive —
URL-based, not header-based. Vercel's post explicitly argues
against this as the primary mechanism because it requires
per-site agent configuration, but many sites use both (header
+ /index.md fallback) for maximum agent coverage.
Subdomain split (md.example.com). Works but requires
DNS, cert management, splits cache and observability stacks.
Loses the single-URL composability argument.
Content-Type-aware middleware at the origin. Skip the
rewrite entirely; let a single handler branch on Accept
and return either HTML or markdown. Works but combines two
concerns in one route (markup generation + format
negotiation); the Vercel approach separates them cleanly.
Seen in¶
- sources/2026-04-21-vercel-making-agent-friendly-pages-with-content-negotiation
— canonical wiki instance. Full Next.js
next.config.tsrewrite + route handler snippets with scoped prefixes (/blog,/changelog). 99.37 % payload-reduction datum (500 KB HTML → 3 KB markdown) on a single blog post.
Related¶
- concepts/markdown-content-negotiation — the primitive this pattern implements.
- patterns/dynamic-index-md-fallback — sibling pattern on Cloudflare stack; same concept, different mechanism.
- patterns/link-rel-alternate-markdown-discovery — the third layer in Vercel's discovery stack; complements this pattern for header-less agents.
- systems/nextjs — the framework hosting the reference implementation.
- systems/vercel-edge-functions — where the rewrite
logic runs in production on
vercel.com. - concepts/llms-txt — composes well;
llms.txtcan point at the clean public URLs knowing markdown is available via the header. - companies/vercel — the implementing company.