Table of contents
An agent that sends Accept: text/markdown to a Next.js site gets the full HTML shell back unless you build the second representation yourself. Next.js does not do content negotiation for you. This guide adds it with one rewrite rule and one Route Handler, which is about 25 lines of code, and the test app behind it returned Markdown to the agent and HTML to the browser on the same URL. AIScan check C1 grades exactly that behaviour.
Quick summary
| If you want to... | Do this | Time |
|---|---|---|
| Serve Markdown on the same URL | A beforeFiles rewrite in next.config.ts that matches the Accept header | 5 min |
| Produce the Markdown | A Route Handler at app/md/blog/[slug]/route.ts | 10 min |
| Keep caches honest | Vary: Accept on the Markdown response, and on the HTML at your CDN | 5 min |
| Confirm it works | npx aiscan-cli yoursite.com, look for C1 | 1 min |
Next.js's own docs are the model. Fetched on 30 September 2026, nextjs.org/docs/app/getting-started/installation with Accept: text/markdown answers 200, content-type: text/markdown; charset=utf-8, vary: Accept, and an x-matched-path of /docs/llm-digest/app/getting-started/installation. That header shows a rewrite to a separate route, which is the pattern below.
Why a rewrite and not a .md URL
A page component cannot change the content type, because a page always renders HTML. A Route Handler can return any Response, so it is the right home for the Markdown. A rewrite then points the agent's request at that handler without changing the URL the agent asked for. The Next.js rewrites reference documents the has field: the rewrite applies only when a header, cookie, host or query value also matches.
beforeFiles matters. Per the same page, beforeFiles rewrites are checked before the filesystem and dynamic routes, so the rewrite wins over app/blog/[slug]/page.tsx. An afterFiles rewrite would never fire, because the page already matches.
Step 1: add the rewrite
- Open next.config.ts in the project root.
- Add a
rewrites()function that returns an object with abeforeFilesarray:
import type { NextConfig } from 'next'
const config: NextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: '/blog/:slug',
has: [{ type: 'header', key: 'accept', value: '(.*)text/markdown(.*)' }],
destination: '/md/blog/:slug',
},
],
}
},
}
export default config
The value is a regex-like string, so (.*)text/markdown(.*) matches text/markdown anywhere in the header, including text/markdown, text/html;q=0.5. Repeat the rule for each section of the site you want covered, for example /docs/:path*.
Step 2: add the Route Handler
- Create the folder app/md/blog/[slug]/ and add route.ts inside it.
- Return Markdown with the right type and a
Varyheader:
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params
const body = await getPostAsMarkdown(slug) // your own loader
if (!body) return new Response('Not found', { status: 404 })
return new Response(body, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
Vary: 'Accept',
},
})
}
getPostAsMarkdown is yours. If your posts already live as MDX or Markdown files, read the file and strip the frontmatter. If they come from a CMS, convert the rich text to Markdown here. Return a real 404 for a slug that does not exist, not a 200 with an error page, or a scanner cannot tell a working route from a catch-all.
Step 3: handle caching
Two caches can serve the wrong representation: a CDN in front of the site and any shared proxy. The fix is Vary: Accept, which tells a cache that the same URL has more than one version.
In our test on Next.js 16.3.7 with next start, the Markdown response carried vary: Accept from the Route Handler. The HTML response did not. Its Vary listed the RSC headers and Accept-Encoding, and adding a headers() entry in next.config.ts or appending the header in proxy.ts did not change that. So set the cache key at the layer that caches: add Accept to the cache key or Vary handling in your CDN or host settings. If you skip that, a CDN that cached the HTML first will hand HTML to the next agent.
Which branch are you on?
| Your setup | What to do |
|---|---|
Self-hosted next start behind Nginx or Cloudflare | Rewrite plus CDN Vary: Accept setting |
| Vercel or another managed host | Same rewrite and handler. Check the response headers before you assume the platform varies on Accept |
Static export (output: 'export') | Rewrites need a server, so this pattern does not apply. Generate .md files at build time instead |
To find your branch, run the verification below and read the vary line.
When it does not work
| Symptom | Cause | Fix |
|---|---|---|
| Agent still gets HTML | The rewrite is in afterFiles, or source does not match the real path | Move it to beforeFiles and compare source with the URL you request |
| Browser shows Markdown | A cache stored the Markdown under the plain URL | Add Accept to the cache key, then purge that path |
| Every URL returns Markdown | source is a broad pattern and the handler never returns 404 | Narrow source and return 404 for unknown slugs |
After changing next.config.ts, rebuild and restart the server before you test.
Check it: scan first, curl second
Run the scan. It is free and needs no account:
npx aiscan-cli yoursite.com
The result lists C1, Markdown content negotiation under content with the evidence string it saw. You can also paste the URL into the scanner.
If you would rather check by hand:
U=https://yoursite.com/blog/some-post
curl -sI -H 'Accept: text/markdown' $U | grep -i -E '^(HTTP|content-type|vary)'
curl -sI -H 'Accept: text/html' $U | grep -i -E '^(HTTP|content-type|vary)'
curl -sI -H 'Accept: text/markdown' $U/no-such-post-9137 | head -1
Expected: the first returns 200 and content-type: text/markdown; charset=utf-8. The second returns text/html. The third returns 404. If the third returns 200, your handler is a catch-all and C1 cannot be trusted. Our documentation sites study found 21 of 52 sites answering Accept: text/markdown with HTTP 200 and a non-Markdown type, which is the failure this control request catches.
Limits and maintenance
Serving Markdown does nothing if the HTML is an empty shell, so read React apps that go invisible to crawlers too. Point your llms.txt links at pages that now have a Markdown form. AIScan fetches with its own identity, so it cannot show what a specific crawler user agent receives. Recheck after any Next.js upgrade that touches routing, and after every new section you add.
More platform walkthroughs are in the guides index. The Next.js hub is at /docs/platforms/nextjs. Run npx aiscan-cli yoursite.com now and read C1.
Frequently asked questions
How do I make Next.js return Markdown when an agent sends Accept: text/markdown?
Add a beforeFiles rewrite in next.config.ts with a has entry that matches the accept header against text/markdown, and point it at a Route Handler that returns a Response with Content-Type text/markdown. The URL stays the same, so browsers keep getting HTML. In a test on Next.js 16.3.7 the same URL returned text/markdown to one request and text/html to the other.
My rewrite never fires and the agent still gets HTML. Why?
The most likely cause is that the rewrite is in afterFiles or returned as a plain array. Those are checked after the filesystem, and app/blog/[slug]/page.tsx already matches, so the page wins. Return an object with a beforeFiles array. Also confirm source matches the exact path you request, and rebuild and restart after editing next.config.ts.
Why does my browser show Markdown instead of the page?
A cache stored the Markdown response under the plain URL. Send Vary: Accept from the Route Handler, add Accept to the cache key at your CDN or host, and purge the affected path. Check the browser request with curl -sI -H 'Accept: text/html' and read the content-type.
Every URL on my site now returns Markdown. What went wrong?
The rewrite source is too broad, or the Route Handler never returns a 404. Narrow source to the sections that have Markdown, for example /blog/:slug, and return new Response('Not found', {status: 404}) when the slug does not exist. A catch-all that answers 200 for any path makes check C1 unreliable.
Does the Markdown route need Vary: Accept?
Yes, wherever a cache sits in front. Vary: Accept tells a cache the same URL has more than one version. In our test the Markdown response carried it from the Route Handler, but the HTML response listed only the RSC headers and Accept-Encoding, so set it at your CDN as well.
Does this work with output: 'export'?
No. Rewrites need a running server, so a static export cannot negotiate on the Accept header. Generate a .md file for each page at build time instead and link to it from your llms.txt.
What does AIScan check C1 actually test?
C1 sends Accept: text/markdown and looks for a Markdown response, and it also probes /index.md. Across 501 scanned sites, 367 were probed with the header and 131 with the path. Passing means a 200 with a Markdown content type, not just a 200.
Do I need this if I already publish llms.txt?
They solve different problems. llms.txt is a map of your pages. C1 is whether each page can be fetched as Markdown. In our study of 52 documentation sites, six published an llms.txt whose links pointed only at HTML, so the agent got a clean map and still had to parse JavaScript-heavy pages.
Related guides
What a Failing AIScan Check Really Says: 2,961 Fail Lines Audited
A failing check on a scan report is supposed to be an instruction. The site owner reads the line, knows what the scanner asked for, knows what came back, and knows what to change. We audited whether…
Rate-limit AI crawlers without blocking them
Most advice on rate limiting AI crawlers stops at "add a limit". The part that costs you is where the limit applies and what it answers. A limit that covers /robots.txt and /llms.txt makes your own…
Which Agent-Readiness Checks Travel Together: 739 Sites, Three Bundles
Fix one agentreadiness check and a second one often moves with it. Fix another and nothing else changes. A scan report lists every check as its own line, so the reader cannot see which lines are…
Platform League Table: WordPress, Next.js, Astro and Shopify Across 728 Sites
Every few weeks someone asks which platform produces the most agentready sites. WordPress, Next.js, Astro and Shopify all have fans, and a ranked table is the answer people expect. We have the data…
