Skip to content

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 1next.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 any Accept header containing text/markdown anywhere, including Accept: 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 /blog and /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 .md files on disk; no build-step synchronisation burden.
  • Single URL. The agent and the browser share the same URL; only the Accept header 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.md require 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 — no Vary: Accept needed. 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, ugly if usage but functional.
  • Caddy: rewrite directive 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 Accept and 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

Last updated · 476 distilled / 1,218 read