AIScan fix guide cover: headline 'A 404 that says 404', a Docusaurus logo chip and a broken page changing to a fixed page.
AIScan fix guide cover: headline 'A 404 that says 404', a Docusaurus logo chip and a broken page changing to a fixed page.
AI Readiness

How to return a real 404 with a recoverable body on Docusaurus

Docusaurus start answers 200 for every URL. Test the real build, add links to the default 404 page, check your host, and verify E1 with curl and AIScan.

AAIScan Content Team 14 Oct 2026 5 min read
#Docusaurus#404#soft 404#AI readiness

This guide covers E1 · Discoverability.

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

SituationWhat you getFix
npm start, any made-up URL200, the dev shellDo not test here
npm run build then npm run serve, made-up URL404 with 404.htmlNone needed
Default 404 bodyMessage only, 0 links in <main>Swizzle NotFound/Content
src/pages/404.jsReplaces the page, but adds /404 to sitemap.xmlPrefer the swizzle
Host rewrites every miss to /index.html with 200Soft 404 on every bad URLRemove 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.html at the root of the publishing source. A made-up path on facebook.github.io returned 404 text/html on 14 October.
  • Cloudflare Pages looks for the closest 404.html up the directory tree. Its docs add that without a top-level 404.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 a 200. 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/ returned 404 like /docs/zzz. A real page with and without its slash returned 200 both ways under serve, but hosts differ, so test your own.
  • baseUrl set to a repo name on GitHub Pages: URLs outside that folder never reach your 404.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