Skip to main content

30 minutes to add a custom 404 page. a RCA.

· 4 min read
Balasubramanyam Evani
Software Engineer

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="/">-&gt; 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 didWhat went wrong
Created src/pages/404.jsCreates a route, doesn't override the 404 handler
Deleted NotFound/index.js, only kept Content/Broke @theme/NotFound resolution entirely
Restarted dev serverSwizzle cache didn't clear, old component still loaded
npx docusaurus clear + restartFinally 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

All views expressed here are my own and do not represent those of my employer or anyone else unfortunate enough to be associated with me.