Content negotiation for Next.js — serve Markdown to LLMs and HTML to browsers from the same URL.
Your Next.js app serves HTML. LLMs, crawlers, and AI agents want Markdown. Today you're stuck choosing between:
- Separate endpoints (
/api/products/123.md) — duplicates your routing, goes stale, and clients have to know about a non-standard URL scheme. - Markdown-only pages — breaks the browser experience for human visitors.
The HTTP Accept header already solves this. A browser sends Accept: text/html. An LLM client sends Accept: text/markdown. Your server should respond accordingly — but Next.js has no built-in way to do this.
next-md-negotiate intercepts requests that ask for Markdown, rewrites them to an internal API route, and returns the Markdown version you define — all transparently, from the same URL.
Browser → GET /products/42 Accept: text/html → your normal Next.js page
LLM agent → GET /products/42 Accept: text/markdown → your Markdown version
No new URLs. No duplicate routing. The client just sets an Accept header.
npm install next-md-negotiateTo scaffold everything automatically, run
npx next-md-negotiate init.
App Router — create app/md-api/[...path]/route.ts:
// app/md-api/[...path]/route.ts
import { createMdHandler } from 'next-md-negotiate';
import { mdConfig } from '@/md.config';
export const GET = createMdHandler(mdConfig);Pages Router — create pages/api/md-api/[...path].ts instead:
// pages/api/md-api/[...path].ts
import { createMdApiHandler } from 'next-md-negotiate';
import { mdConfig } from '@/md.config';
export default createMdApiHandler(mdConfig);// md.config.ts
import { createMdVersion } from 'next-md-negotiate';
export const mdConfig = [
createMdVersion('/products/[productId]', async ({ productId }) => {
const product = await db.products.find(productId);
return `# ${product.name}\n\nPrice: $${product.price}\n\n${product.description}`;
}),
createMdVersion('/blog/[slug]', async ({ slug }) => {
const post = await db.posts.find(slug);
return `# ${post.title}\n\n${post.content}`;
}),
];Parameters are type-safe — { productId } is inferred from the [productId] in the pattern.
This works with both App Router and Pages Router, on any supported Next.js version. Routes are generated directly from your config — no duplication.
// next.config.ts
import { createRewritesFromConfig } from 'next-md-negotiate';
import { mdConfig } from './md.config';
export default {
async rewrites() {
return {
beforeFiles: createRewritesFromConfig(mdConfig),
};
},
};That's it. Requests with Accept: text/markdown get your Markdown. Everything else is untouched.
Accept: text/markdown?
│
┌─────────┴─────────┐
│ yes │ no
▼ ▼
Route matches? Normal Next.js
│ page renders
┌──────┴──────┐
│ yes │ no
▼ ▼
Rewrite to Pass through
/md-api/...
│
▼
Catch-all handler
runs your function
│
▼
200 text/markdown
- The rewrite/middleware/proxy layer checks the
Acceptheader fortext/markdown,application/markdown, ortext/x-markdown. - If the request matches a configured route, it internally rewrites to
/md-api/.... - The catch-all route handler matches the path against your registry and calls your function.
- Your function returns a Markdown string. The handler sends it back as
text/markdown; charset=utf-8.
The next.config rewrites approach covers most use cases. If you need content negotiation to live in your request-handling layer instead — for example, you already have a middleware.ts handling auth/i18n/redirects, or you're on Next.js 16+ using proxy.ts — use createNegotiatorFromConfig:
// middleware.ts or proxy.ts
import { createNegotiatorFromConfig } from 'next-md-negotiate';
import { mdConfig } from './md.config';
const md = createNegotiatorFromConfig(mdConfig);
export function middleware(request: Request) {
// Check markdown negotiation first
const mdResponse = md(request);
if (mdResponse) return mdResponse;
// ...your other middleware logic (auth, i18n, etc.)
}Routes are read from mdConfig — the same single source of truth used by rewrites and the handler.
| Method | Best for |
|---|---|
next.config rewrites |
Most projects. Zero runtime overhead — Next.js handles the routing natively. Works with both App Router and Pages Router. |
middleware.ts / proxy.ts |
Projects that already have a middleware or proxy and want all request interception in one place. |
# Normal HTML response
curl http://localhost:3000/products/42
# Markdown response
curl -H "Accept: text/markdown" http://localhost:3000/products/42LLM agents visiting your pages get HTML with no indication a cleaner Markdown version exists. The LlmHint component adds a <script type="text/llms.txt"> tag — invisible to browsers, visible to LLMs — that tells agents they can re-request with Accept: text/markdown. Inspired by Vercel's inline LLM instructions proposal.
Add hints to all pages that have a Markdown version defined in your config:
npx next-md-negotiate add-hintsRemove them:
npx next-md-negotiate remove-hintsImport and add the LlmHint component to any page:
import { LlmHint } from 'next-md-negotiate';
export default function ProductPage() {
return (
<>
<LlmHint />
<div>{/* your page content */}</div>
</>
);
}Set a global default in your config — it applies to all routes unless overridden per-route:
// md.config.ts
export const defaultHintText = 'Markdown available. Re-request with Accept: text/markdown';
export const mdConfig = [
createMdVersion('/products/[productId]', handler), // uses default
createMdVersion('/blog/[slug]', handler, { hintText: 'Per-route hint' }), // overrides default
createMdVersion('/internal', handler, { skipHint: true }), // skipped entirely
];Or directly on the component:
<LlmHint message="Custom instructions for LLM agents" />Defines a Markdown version for a route.
createMdVersion('/products/[productId]', async ({ productId }) => {
return `# Product ${productId}`;
});Options:
| Option | Type | Default | Description |
|---|---|---|---|
hintText |
string |
undefined |
Custom message for the LlmHint component when using add-hints. Overrides defaultHintText. |
skipHint |
boolean |
undefined |
Skip this route when running add-hints |
Supported patterns:
- Named params:
/products/[productId] - Catch-all params:
/docs/[...slug] - Multiple params:
/[org]/[repo] - Static routes:
/about
Creates an App Router handler for the catch-all route. Assign it to GET.
export const GET = createMdHandler(mdConfig);Creates a Pages Router API handler for the catch-all route.
export default createMdApiHandler(mdConfig);Generates Next.js rewrite rules directly from your mdConfig array. The recommended approach for most projects.
createRewritesFromConfig(mdConfig)Creates a middleware/proxy handler directly from your mdConfig array. Returns a Response for markdown requests, or undefined to pass through.
const md = createNegotiatorFromConfig(mdConfig);Options (shared by both):
| Option | Type | Default | Description |
|---|---|---|---|
internalPrefix |
string |
'/md-api' |
Internal rewrite destination prefix |
React component that renders a <script type="text/llms.txt"> tag to help LLM agents discover the Markdown version of a page.
<LlmHint />
<LlmHint message="Custom instructions for agents" />| Prop | Type | Default | Description |
|---|---|---|---|
message |
string |
'You are viewing the HTML version of this page...' |
The hint text inside the script tag |
| Command | Description |
|---|---|
next-md-negotiate init |
Scaffold route handler, config file, and rewrites. Offers to add LLM hints when routes are already defined. Pass --add-hints to skip the prompt. |
next-md-negotiate add-hints |
Inject LlmHint into page files for all configured routes |
next-md-negotiate remove-hints |
Remove LlmHint from page files for all configured routes |
Lower-level versions that accept explicit route arrays. Use the config-based versions above unless you have a reason not to.
| Option | Type | Default | Description |
|---|---|---|---|
routes |
string[] |
required | Route patterns to negotiate |
internalPrefix |
string |
'/md-api' |
Internal rewrite destination prefix |
MIT