30 minutes to add a custom 404 page. a RCA.
I wanted a custom 404 page. a little peepoLost gif and a "take me home" button. Thirty minutes later I had broken the dev server twice and learned more about Docusaurus internals than I planned to.
Here's what happened.
what i tried first (wrong)
The obvious thing: create src/pages/404.js. It's a pages directory, 404 is a page, right?
// src/pages/404.js
export default function NotFound() {
return <div>my custom 404</div>;
}
In dev: it flashed briefly, then the default "Page Not Found" page took over. In prod (Cloudflare Pages): same thing. Default page. My file completely ignored.
Why it doesn't work:
src/pages/404.js creates a route at /404. It doesn't replace Docusaurus's internal
404 handler. When you navigate to an unknown URL, Docusaurus's React Router says
"no route found" and renders @theme/NotFound — the built-in component. Your
pages/404.js is a different route entirely, never triggered for actual 404s.
The host (Cloudflare) might also serve 404.html from the build output, but
client-side navigation within the app always goes through Docusaurus's router,
not the file system.
what actually works — swizzling
Docusaurus has a concept called swizzling:
you can override theme components by creating them in src/theme/.
The component that handles 404s is @theme/NotFound. So:
npm run swizzle @docusaurus/theme-classic NotFound
In Docusaurus v2 this created a single file: src/theme/NotFound.js. Simple.
In Docusaurus v3 it creates two files:
src/theme/NotFound/index.js # page wrapper (provides Layout)
src/theme/NotFound/Content/index.js # the actual content ("Page Not Found" text)
what i actually did (also wrong)
I read that v3 split the component, so I deleted NotFound/index.js and only
created NotFound/Content/index.js thinking I just needed the content part.
✖ Module not found: Can't resolve '@theme/NotFound'
Right. By deleting index.js, I deleted the component entirely. Docusaurus needs
NotFound/index.js to exist to resolve @theme/NotFound. The Content subcomponent
can't exist without the parent.
the fix that works
You need both files. index.js is a passthrough wrapper:
// src/theme/NotFound/index.js
import React from "react";
import NotFound from "@theme-original/NotFound";
export default function NotFoundWrapper(props) {
return <NotFound {...props} />;
}
And Content/index.js is where you put your actual custom content:
// src/theme/NotFound/Content/index.js
import React from "react";
import Link from "@docusaurus/Link";
export default function NotFoundContent() {
return (
<main style={{ textAlign: "center", padding: "4rem 1rem" }}>
<img
src="https://cdn3.emoji.gg/emojis/29604-peepolost.png"
width="80px"
alt="peepoLost"
/>
<h1>404 — genuinely no idea where you are</h1>
<p>this page doesn't exist. you might be more lost than me.</p>
<Link to="/">-> take me home</Link>
</main>
);
}
one more thing: swizzle doesn't hot reload
After getting the file structure right, changes still weren't showing in dev.
This is a known Docusaurus issue (#10238) —
swizzled components don't support hot reload. You have to fully restart the dev server
every time you change them. Ctrl+C, npm start, wait.
And if the Docusaurus cache (.docusaurus/) is stale, even a restart isn't enough.
Full clean required:
npx docusaurus clear && npm start
RCA summary
| What I did | What went wrong |
|---|---|
Created src/pages/404.js | Creates a route, doesn't override the 404 handler |
Deleted NotFound/index.js, only kept Content/ | Broke @theme/NotFound resolution entirely |
| Restarted dev server | Swizzle cache didn't clear, old component still loaded |
npx docusaurus clear + restart | Finally worked |
The correct structure is both files. The parent index.js as a wrapper, the
Content/index.js with your actual UI. The GitHub discussion (#6030)
has the right answer — just note that it was written for v2, and the file
structure changed in v3.
Anyway. peepoLost is on the 404 page now. thirty minutes well spent.
-- Bala
