# 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

```ts
// middleware.ts

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

```ts
// app/api/markdown/[[...slug]]/route.ts


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`](https://github.com/mixmark-io/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:

```ts
// app/blog/[slug]/route.ts


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.

```ts
// next.config.ts

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:**

<ul class="tradeoffs">
  <li><Icon name="check" /><span>Zero runtime compute, fully static config</span></li>
  <li><Icon name="check" /><span>Forward-compatible with Next's proxy rename</span></li>
  <li><Icon name="x" /><span>Doesn't honor <code>q=0</code> rejections (a client explicitly refusing Markdown still gets rewritten)</span></li>
  <li><Icon name="x" /><span>Doesn't rank by q-value — a higher-q <code>text/html</code> loses to a lower-q <code>text/markdown</code> as long as Markdown appears first in the header</span></li>
  <li><Icon name="x" /><span>Can't emit <code>Vary: Accept</code> from rewrites alone; set it in the route handler's response</span></li>
</ul>

This is the approach the
[Vercel Labs markdown-to-agents template](https://github.com/vercel-labs/markdown-to-agents)
uses. Good for simple sites; use middleware when you need correctness.

## Hosting-specific notes

### Vercel

- `Cache-Control: s-maxage=N` triggers the edge cache, which honors
  `Vary: Accept`.
- Both representations cache independently; warm both after deploy.
- Check `x-vercel-cache: HIT` on responses to confirm caching.
- **Bundle source `.md` files** via `outputFileTracingIncludes` in
  `next.config.ts` (see the rewrites section above). Without it, your
  route handler can read the files in `npm run dev` but throws
  `ENOENT` in production because Next's file tracer didn't include
  them in the deployment.

### Cloudflare Pages / Workers

- Cloudflare's edge cache honors `Vary: Accept` with Cache Rules.
- Or enable
  [Markdown for Agents](/guides/cloudflare-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 `Accept` header through and not
  strip the `Vary` header on the way back.

### Netlify

- Same pattern as Vercel — `Vary: Accept` in the response,
  `Cache-Control: s-maxage=N` to trigger edge caching.

## Verify

```bash
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](https://vercel.com/templates/ai/markdown-to-agents-html-to-humans)
  — Vercel template with one-click deploy.