Table of contents
- Quick summary
- Why Docusaurus needs two halves
- Step 1: install the plugin
- Step 2: write a Markdown copy of every page
- Step 3: check what an agent will read
- Step 4: route by Accept header in nginx
- Step 5: send one URL several Accept values
- Which route are you on
- Two traps I hit
- Check it: scan first, curl second
- What this does not cover
- Next step
An AI agent that sends Accept: text/markdown to a Docusaurus page gets HTML back, because docusaurus build writes files and a file cannot read a request header. AIScan check C1 grades exactly that request. This guide makes the build write a Markdown copy of every doc and blog page, then uses one nginx rule to hand that copy to agents at the same URL. I built and ran all of it on Docusaurus 3.10.2, docusaurus-plugin-llms 0.6.1 and nginx 1.24.0 on 4 October 2026. Browsers keep getting HTML.
Quick summary
| If you want to... | Do this | Time |
|---|---|---|
Write a .md file next to every built page | Add docusaurus-plugin-llms with generateMarkdownFiles: true | 5 min |
| Include blog posts and drop MDX imports | Add includeBlog and excludeImports | 1 min |
| Serve the copy at the original URL | An nginx map on the Accept header plus try_files | 10 min |
| Confirm it works | npx aiscan-cli yoursite.com, then read the C1 row | 1 min |
Why Docusaurus needs two halves
Check C1 re-requests a page with Accept: text/markdown and passes when the server answers text/markdown. That is proactive negotiation as defined in RFC 9110: one URL, several representations, chosen by a request header. A /page.md file alone does not pass, because the scanner asks the original URL.
Docusaurus can supply the first half only with help. It compiles your .md and .mdx sources into React, so the build output holds docs/intro/index.html and no Markdown at all. The docusaurus-plugin-llms package fills that gap, and the web server does the second half. Docusaurus has no request-time code in a static build.
Step 1: install the plugin
From the project root, run npm install docusaurus-plugin-llms. It lists @docusaurus/core ^3.0.0 as a peer dependency.
Step 2: write a Markdown copy of every page
Open docusaurus.config.js and add a plugins array next to presets:
plugins: [
['docusaurus-plugin-llms', {
generateMarkdownFiles: true,
includeBlog: true,
excludeImports: true,
}],
],
Also set url to your real site address. The plugin writes absolute links into llms.txt from that value, and the scaffold ships with https://your-docusaurus-site.example.com.
Run npm run build. On the scaffold from create-docusaurus the log said Generating individual markdown files... and Stats: 13 documents processed. Each page now has a twin beside its folder:
build/docs/intro/index.html
build/docs/intro.md
build/blog/first-blog-post/index.html
build/blog/first-blog-post.md
I read all three option names from the package source. generateMarkdownFiles and includeBlog both default to false, so with the defaults you get llms.txt and llms-full.txt but no per-page files, and no blog pages at all.
Step 3: check what an agent will read
Open one generated file. Front matter was gone and the title became a # heading. Admonitions stayed as Docusaurus directive syntax (:::tip[My tip]), which an agent reads fine. Two things did not convert:
excludeImports: trueremovedimportlines, but an inlineexport const Highlight = ...block and the<Highlight>tags that use it stayed in the output. Pages that lean on custom MDX components will carry that markup into the Markdown.- The home page and the
/docscategory pages have no twin. Only pages built from a source file do.
Read your most component-heavy page before you ship. If it is unreadable, the plugin's preserveComponents option exists for this case, but I did not test it.
Step 4: route by Accept header in nginx
Docusaurus builds docs/intro/index.html for the route /docs/intro, and the twin sits at /docs/intro.md. So the rule is "append .md to the path when the client prefers Markdown". In the http block of /etc/nginx/nginx.conf or a file it includes, add:
types { text/markdown md; }
map $http_accept $md_suffix {
default "";
"~*^text/markdown(?!\s*;\s*q=0(\.0+)?(,|$))" ".md";
}
map $uri $bare_uri {
default $uri;
"~^(?<p>.+?)/+$" $p;
}
Then, in your server block, point root at the build folder:
add_header Vary Accept always;
location / {
try_files $bare_uri$md_suffix $uri $uri/index.html =404;
}
error_page 404 /404.html;
Reload with nginx -t && nginx -s reload. The second map strips a trailing slash so /docs/intro/ and /docs/intro both find intro.md. The first pattern matches only when the header begins with text/markdown and that type is not q=0. A browser header that starts with text/html never matches, and Vary: Accept tells caches the same URL has two answers.
Step 5: send one URL several Accept values
I requested /docs/intro with different headers and read the status and content type:
| Accept header | Content-Type returned |
|---|---|
| none | text/html |
text/markdown | text/markdown |
text/markdown, text/html;q=0.8 | text/markdown |
text/html, text/markdown;q=0.5 | text/html |
*/* | text/html |
text/markdown;q=0 | text/html |
text/markdown;q=0, text/html | text/html |
All returned 200 with Vary: Accept. /docs/intro/ with a trailing slash and /docs/tutorial-basics/create-a-page answered Markdown too, and /docs/nope returned 404. The home page / answered text/html even for Accept: text/markdown, which is the gap from Step 3.
Which route are you on
Pick the branch that matches how the built site is served:
- You run nginx yourself. Use Step 4 as written.
- You sit behind a CDN or edge platform. Put the same rule in its rewrite layer and keep
Vary: Accepton both answers. Vercel documents header conditions on rewrites; I did not test it. - You publish to GitHub Pages. The files exist and are linked from
llms.txt, but C1 cannot pass because the host serves strictly by path.
To find your branch, run the curl -sI pair under the check section against your live URL. A server: header naming nginx means the first branch applies.
Two traps I hit
The first is the MIME type. Without types { text/markdown md; }, nginx serves intro.md as text/plain on Ubuntu, and C1 wants text/markdown. The second is the host. GitHub Pages and other file-only hosts cannot read the header, so Steps 1 to 3 still produce linked .md files but C1 will not pass. Vercel's vercel.json reference documents has conditions on a request header, which is the feature a rewrite like this needs. I tested only nginx, so confirm anything else with the commands below.
Check it: scan first, curl second
Run the scanner against the live site and read the C1 row:
npx aiscan-cli yoursite.com
By hand, request one doc URL twice and compare:
curl -sI -H "Accept: text/markdown" https://yoursite.com/docs/intro
curl -sI -H "Accept: text/html" https://yoursite.com/docs/intro
A pass is content-type: text/markdown on the first, content-type: text/html on the second, and vary: accept on both. If the first still says HTML, the server is not reading the header; start at Step 4. If it returns 404, the .md file is missing from build/; start at Step 2.
What this does not cover
The scan makes one request from one place. It cannot see a CDN in front of nginx caching the wrong variant, so purge the cache after the change and confirm Vary: Accept survives it. Re-run the scan after upgrading Docusaurus or the plugin, since the plugin is a third-party package at version 0.6.1.
Next step
Run npx aiscan-cli yoursite.com and read C1. The same check on other stacks is in serving text/markdown on Hugo and on Astro. Docusaurus's other readiness files are in publishing llms.txt on Docusaurus, and the population-level picture is in documentation sites and AI agent readiness. The check page is /docs/checks/content and every other fix is in /guides.
Frequently asked questions
How do I make Docusaurus return Markdown when an agent sends Accept: text/markdown?
Add docusaurus-plugin-llms with generateMarkdownFiles: true so the build writes a .md file beside each page, then put a header-based rule in front of the build folder (nginx on the test site). On Docusaurus 3.10.2 and nginx 1.24.0, one URL returned text/markdown to one request and text/html to the other.
Can Docusaurus do the content negotiation by itself?
No. A static build has no request-time code, and docusaurus build writes HTML only. The plugin produces the Markdown files, and the web server or CDN has to choose which file to send.
Why does my build contain llms.txt but no .md files?
generateMarkdownFiles defaults to false in docusaurus-plugin-llms 0.6.1. Set it to true in the plugin options, rebuild, and look for build/docs/intro.md beside build/docs/intro/index.html.
Why does nginx serve my Markdown as text/plain?
The stock Ubuntu mime.types file has no entry for md, so nginx falls back to text/plain and check C1 expects text/markdown. Add types { text/markdown md; } to the http block and reload.
Why does Accept: text/markdown on my home page return HTML?
Only pages built from a source file get a .md twin. The home page and the docs category pages have none in the test build, so the rule falls through to index.html. Add the content as a docs page if agents should reach it as Markdown.
Why is there JSX or an export const line in my generated Markdown?
excludeImports removes import lines only. An inline export const component definition and the tags that use it stayed in the output on the test site. Move shared components out of the page, or read the output before you ship.
Will browsers start receiving Markdown?
Not with the pattern shown. It matches only when the Accept header begins with text/markdown and that type is not q=0. A header starting with text/html, and /, both returned text/html in testing.
Does this work on GitHub Pages or other file-only hosts?
The Markdown files get built and linked from llms.txt, but a host that serves files strictly by path cannot read the Accept header, so C1 will not pass there. I tested only nginx; check your host's documentation for header-based rewrites and confirm with the curl commands above.
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…
How to return a real 404 with a recoverable body on Docusaurus
Docusaurus builds a real 404.html for you, and then the place you test it hides it. Run npm start, type a madeup URL and you get a friendly page with a 200 status. The production build behaves…
