Nuxt / Nitro

Implement strict Accept negotiation in Nitro server middleware and serve Markdown from API routes with Vary: Accept, 406, and Link: rel="alternate".

In Nuxt 3, do negotiation in Nitro middleware once, then branch in routes.

server/middleware/negotiate.ts

import { defineEventHandler, getHeader, setHeader, setResponseStatus, createError } from 'h3';
import { readFile, access } from 'node:fs/promises';

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();
      if (!type) return null;
      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 };
    })
    .filter((e): e is AcceptEntry => e !== null);
}

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, produces: string[]): string | null {
  if (!header) return produces[0] ?? null;
  const entries = parseAccept(header);
  if (entries.length === 0) return produces[0] ?? null;

  let best: string | null = null;
  let bestQ = -1;
  let bestPos = Infinity;

  for (const candidate of produces) {
    let matched: AcceptEntry | null = null;
    let matchedPos = Infinity;
    for (let i = 0; i < entries.length; i++) {
      const e = entries[i];
      if (!matches(e, candidate)) continue;
      if (matched === null || e.specificity > matched.specificity || (e.specificity === matched.specificity && i < matchedPos)) {
        matched = e;
        matchedPos = i;
      }
    }
    if (!matched || matched.q <= 0) continue;
    if (matched.q > bestQ || (matched.q === bestQ && matchedPos < bestPos)) {
      bestQ = matched.q;
      bestPos = matchedPos;
      best = candidate;
    }
  }

  return best;
}

function appendVaryAccept(event: any): void {
  const existing = getHeader(event, 'vary');
  if (!existing) return setHeader(event, 'Vary', 'Accept');
  const tokens = existing.split(',').map((s) => s.trim().toLowerCase());
  if (!tokens.includes('accept')) setHeader(event, 'Vary', `${existing}, Accept`);
}

// Resolve a request pathname to its Markdown sibling on disk.
// Called for both `/docs/foo` and `/docs/foo.md`.
// Return null for any URL that has no Markdown representation — the
// middleware short-circuits and Nuxt serves the request normally.
// Customize the regex and path to match your content layout.
function siblingFor(urlPath: string): { canonical: string; mdPath: string } | null {
  const clean = urlPath.replace(/\/$/, '');
  const isMdRequest = clean.endsWith('.md');
  const base = isMdRequest ? clean.slice(0, -3) : clean;
  const m = base.match(/^\/docs\/([^/]+)$/);
  if (!m) return null;
  return { canonical: base, mdPath: `content/docs/${m[1]}.md` };
}

async function exists(p: string): Promise<boolean> {
  try { await access(p); return true; } catch { return false; }
}

export default defineEventHandler(async (event) => {
  const sibling = siblingFor(event.path);
  if (!sibling) return; // not a negotiation candidate — hands back to Nuxt

  // 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.
  if (event.path.replace(/\/$/, '').endsWith('.md')) {
    try {
      const body = await readFile(sibling.mdPath, 'utf8');
      setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8');
      appendVaryAccept(event);
      return body;
    } catch {
      throw createError({ statusCode: 404 });
    }
  }

  const accept = getHeader(event, 'accept') ?? null;
  const chosen = preferredType(accept, ['text/html', 'text/markdown']);
  const acceptsHtml = !!preferredType(accept, ['text/html']);

  appendVaryAccept(event);

  // Return 406 as plain text via setResponseStatus + raw return, not
  // createError — an h3 error is rendered by Nuxt's error page as HTML,
  // which contradicts the Content-Type we'd want on a negotiation
  // failure and may lose the Vary header we set above.
  if (chosen === null && accept) {
    setResponseStatus(event, 406, 'Not Acceptable');
    setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
    return 'Not Acceptable\n\nAvailable: text/html, text/markdown\n';
  }

  // Markdown preferred on the canonical URL: short-circuit Nuxt's page
  // renderer with the raw `.md` body.
  if (chosen === 'text/markdown') {
    try {
      const body = await readFile(sibling.mdPath, 'utf8');
      setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8');
      return body;
    } catch {
      if (!acceptsHtml) {
        setResponseStatus(event, 406, 'Not Acceptable');
        setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
        return 'Not Acceptable\n\nMarkdown representation not available and HTML is not acceptable.\n';
      }
    }
  }

  // HTML: let `pages/docs/[slug].vue` render. Advertise the sibling
  // via Link (RFC 8288) so agents like Codex can discover it without
  // sending Accept.
  if (await exists(sibling.mdPath)) {
    const existing = getHeader(event, 'link');
    const linkValue = `<${sibling.canonical}.md>; rel="alternate"; type="text/markdown"`;
    setHeader(event, 'Link', existing ? `${existing}, ${linkValue}` : linkValue);
  }
});

HTML page

The canonical URL (/docs/foo) still serves both. Browsers get HTML rendered from a normal Nuxt page component:

<!-- pages/docs/[slug].vue -->
<script setup lang="ts">
const slug = useRoute().params.slug as string;
// Fetch your content here (e.g. useAsyncData + readFile or a CMS call).
</script>

<template>
  <article>
    <h1>{{ slug }}</h1>
    <!-- markdown-rendered HTML here -->
  </article>
</template>

The negotiation middleware runs before Nuxt’s page renderer. When Accept: text/markdown wins, the middleware returns the raw .md body and Nitro ends the request there. Otherwise the page component renders HTML and the middleware has already attached the Link header advertising the sibling.

Verify

curl -sI -H "Accept: text/markdown" https://your-site.com/docs/intro
curl -sI -H "Accept: application/pdf" https://your-site.com/docs/intro