Table of contents
Docusaurus builds a real 404.html for you, and then the place you test it hides it. Run npm start, type a made-up URL and you get a friendly page with a 200 status. The production build behaves differently, and so does each host you can put it on. This guide reproduces both on Docusaurus 3.10.2, adds links to the default 404 body (which has none in its content area), and gives the curl that proves the fix. Tested on 14 October 2026 with a fresh classic site, Node 22.22.0.
Quick summary
| Situation | What you get | Fix |
|---|---|---|
npm start, any made-up URL | 200, the dev shell | Do not test here |
npm run build then npm run serve, made-up URL | 404 with 404.html | None needed |
| Default 404 body | Message only, 0 links in <main> | Swizzle NotFound/Content |
src/pages/404.js | Replaces the page, but adds /404 to sitemap.xml | Prefer the swizzle |
Host rewrites every miss to /index.html with 200 | Soft 404 on every bad URL | Remove the rewrite |
Fastest route: run npx aiscan-cli yoursite.com and read E1, Correct 404 handling (check E1, Discoverability). The by-hand check is in Step 4.
Why npm start returns 200 for everything
The dev server is built to hand every path to the client-side router. I started docusaurus start on port 3200 and requested five URLs that do not exist, including /zzz-nope and /docs/zzz. Every one returned 200 text/html with the same 1,873-byte shell. The router then shows the "Page Not Found" screen in the browser, so the page looks right and the status is wrong. Nothing about your site is broken. You are testing the wrong server.
Step 1: Test the production build, not the dev server
npm run build
npm run serve -- --port 3100
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:3100/zzz-nope
curl -s -o /dev/null -w '%{http_code}\n' localhost:3100/docs/zzz
Both returned 404, with the body of build/404.html. A missing /img/zzz.png and a missing /assets/js/zzz.js also got 404, so a stale link to an old asset reads correctly too. If your own site returns 200 here, the cause is in your config. If it returns 404 locally and 200 live, the cause is your host (Step 3).
Step 2: Give the 404 page something to follow
AIScan grades the body of a 404 as well as its status. The page Docusaurus ships says only that you should "contact the owner of the site that linked you to the original URL". In my build the whole page held 11 links, all in the navbar and footer, and none inside <main>. An agent that lands there has no next step.
Eject the content component. Docusaurus marks NotFound/Content as unsafe to swizzle, so the command needs --danger:
npm run swizzle @docusaurus/theme-classic NotFound/Content -- --eject --danger
That writes src/theme/NotFound/Content/index.js. Replace its body with real links:
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Heading from '@theme/Heading';
export default function NotFoundContent({className}) {
return (
<main className={clsx('container margin-vert--xl', className)}>
<Heading as="h1" className="hero__title">Page not found</Heading>
<p>This page is not on example.com. Try one of these instead:</p>
<ul>
<li><Link to="/docs/intro">Documentation</Link></li>
<li><Link to="/blog">Blog</Link></li>
<li><a href="/sitemap.xml">Sitemap</a></li>
</ul>
</main>
);
}
After npm run build, build/404.html had one <h1> and three links inside <main>, and /zzz still answered 404. Name your site in the sentence and point at the pages people actually want.
Why not src/pages/404.js?
It works, and it replaced the built-in page completely in my test. The catch is that Docusaurus treats it as an ordinary page. The build produced build/404/index.html and sitemap.xml listed /404. With the swizzle, the sitemap held no 404 entry at all. A sitemap that advertises your error page is the opposite of what E1 is trying to confirm.
Step 3: Check what your host does with a miss
Docusaurus only emits 404.html. The host decides whether to use it.
- GitHub Pages serves a
404.htmlat the root of the publishing source. A made-up path onfacebook.github.ioreturned404 text/htmlon 14 October. - Cloudflare Pages looks for the closest
404.htmlup the directory tree. Its docs add that without a top-level404.html, Pages assumes a single-page app and serves the root for any unknown path. Docusaurus always emits that file, so keep it in the output directory. - Netlify needs no rule for a
404.html. Its redirect docs describe the automatic custom 404 page. The trap is a rule such as/* /index.html 200, which the same docs give as the single-page-app recipe. Copied into a Docusaurus project it turns every bad URL into a200. I did not deploy to Netlify; this comes from its documentation.
docusaurus.io itself passes: /zzz-nope-9f3a and /docs/zzz-nope both returned 404 text/html.
Step 4: Verify the fix
With AIScan (default): run npx aiscan-cli yoursite.com. Open Discoverability and find E1, Correct 404 handling. It should read pass.
By hand with curl:
curl -s -o /dev/null -w '%{http_code}\n' https://yoursite.com/this-page-does-not-exist-9f3a
curl -s https://yoursite.com/this-page-does-not-exist-9f3a | grep -c '<a '
Expected: 404, then a link count above zero. Add -I and confirm there is no location: header. Test one path under /docs/ as well as one at the root, because a rewrite rule scoped to a folder only shows up there.
Edge cases
- Trailing slashes do not change the result:
/docs/zzz/returned404like/docs/zzz. A real page with and without its slash returned200both ways underserve, but hosts differ, so test your own. baseUrlset to a repo name on GitHub Pages: URLs outside that folder never reach your404.html. They hit the account-level site.- A CDN rule that swaps 404s for a custom 200 page hides a correct origin.
Next step
Run the scan, check E1 on your own domain, and read the rest of the series in AIScan's guides. Related reading: why soft 404s mislead agents, the Astro version of this fix, AI readiness setup on Docusaurus, and the Discoverability checks.
Frequently asked questions
Why does my Docusaurus site return 200 for a page that does not exist?
If you are looking at npm start, that is expected: the dev server hands every path to the client router and answers 200. Test with npm run build and npm run serve instead. If the live site also returns 200, your host is rewriting misses to index.html.
curl returns 404 on localhost:3100 but 200 on my live site. Why?
The build is fine and the host is not. Look for a rewrite such as /* /index.html 200 in _redirects, netlify.toml or your CDN rules, and confirm 404.html is in the deployed output.
My 404 page loads in the browser but AIScan still fails E1. What is wrong?
The status line is probably 200. Run curl -s -o /dev/null -w '%{http_code}' on a made-up URL. A browser shows the page either way, so only the status tells you.
Cloudflare Pages serves my index page for every unknown URL. How do I stop that?
Cloudflare Pages switches to single-page-app behaviour when there is no top-level 404.html. Docusaurus builds one, so check it survived your build step and sits at the root of the output directory.
Where is the 404 page in a Docusaurus project?
Docusaurus generates build/404.html from the NotFound theme component. You change its content by swizzling NotFound/Content, which needs the --danger flag, or by adding src/pages/404.js.
Why did /404 appear in my sitemap.xml after I added src/pages/404.js?
Docusaurus treats that file as an ordinary page and builds build/404/index.html, so the sitemap plugin lists it. Swizzling NotFound/Content left no 404 entry in my build.
Does the trailing slash change whether a missing page returns 404?
Not in my test: /docs/zzz and /docs/zzz/ both returned 404 under docusaurus serve. Hosts vary, so run the curl against your own domain with and without the slash.
Do I need to touch the Docusaurus config to get a real 404?
No. A normal build already writes 404.html. The work is in the body of that page, in testing the production build rather than npm start, and in the host settings.
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…
How to return a real 404 with a recoverable body on Wix
Wix gets the status code right and leaves the page empty. Request a URL that does not exist on a Wix site and you get a genuine 404, but the body is a scriptbuilt shell with no text and no links in…
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 Lovable
Lovable can return a real 404 or a soft 200 for the same missing URL, and which one you get depends on the stack your project was built on. AIScan check E1 Correct 404 handling grades both the status…
