---
title: "How to return a real 404 with a recoverable body on Docusaurus"
slug: fix-soft-404-docusaurus
published: 2026-10-14T13:21:18.714757+00:00
updated: 2026-10-14T13:21:18.714757+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:E1, platform:docusaurus, Docusaurus, 404, soft 404, AI readiness
description: "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."
url: https://aiscan.site/blog/fix-soft-404-docusaurus
---

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

```bash
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`:

```bash
npm run swizzle @docusaurus/theme-classic NotFound/Content -- --eject --danger
```

That writes `src/theme/NotFound/Content/index.js`. Replace its body with real links:

```jsx
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](https://developers.cloudflare.com/pages/configuration/serving-pages/) 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](https://docs.netlify.com/manage/routing/redirects/redirect-options/) 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:**

```bash
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](https://aiscan.site/guides). Related reading: [why soft 404s mislead agents](https://aiscan.site/blog/soft-404-agent-discovery-problem), the [Astro version of this fix](https://aiscan.site/blog/fix-soft-404-astro), [AI readiness setup on Docusaurus](https://aiscan.site/blog/ai-readiness-setup-docusaurus), and the [Discoverability checks](https://aiscan.site/docs/checks/discoverability).

