# 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`

```ts


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:

```vue
<!-- 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

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