Chapter 6 of 8. Prerequisites: Chapter 2 gave you
trailingSlash: true, and Chapter 5 put a.htaccessat your web root. This chapter adds one more line to that file, plus the diagnosis that tells you why you need it.
Here is the shape of the false alarm. You build, you deploy, and you visit a URL that does not exist. Instead of your carefully styled 404 page, you get the host's grey default: "Not Found. The requested URL was not found on this server." Your not-found.tsx renders fine in next dev. It looks like the export dropped your 404 page on the floor.
It didn't. The page is sitting in your out/ folder. What the host lacks is any instruction about which file to serve for a miss. On this site the gap took a few minutes to spot, and only because I'd read the Next.js issue first. Without that context it reads like a broken build.
What trailingSlash actually emits
Next.js has a convention for static hosts: a file called 404.html at the root of your output is the one a server hands back for any not-found path. Most static hosts (GitHub Pages, Netlify, S3 static sites) look for exactly that filename. The export writes it, and everything works with no configuration.
Setting trailingSlash: true disrupts that convention. With trailing slashes on, every route exports as a directory with an index.html inside it: /writing/ becomes out/writing/index.html rather than out/writing.html. That is the behaviour you want for clean URLs on cPanel, and Chapter 2 is where you turned it on.
The catch is that Next.js applies the same directory rule to the 404 page when you have a custom one. Instead of out/404.html, the build can emit out/404/index.html. This is vercel/next.js issue #51042: with output: 'export', trailingSlash: true, and a custom error page, the build "generates 404/index.html instead of a root-level 404.html." The expected file is 404.html at the root; the actual output puts it one directory down as index.html.
Now the host's default lookup fails. Apache and LiteSpeed are not searching for 404/index.html, so they never find your page and fall back to their own built-in message. That is the entire bug. Your React is intact. The file simply landed somewhere the host's convention never checks, and nobody told the host where to look.
Diagnose it in ten seconds
Before you touch a config, confirm what your build actually produced. The answer is on disk. Run the build and list the 404 artefacts:
npm run build
find out -iname '404*' -o -path '*/404/*'
You will see one of two shapes. If you get out/404.html, the plain-filename convention holds and most hosts serve it without help. If instead you get out/404/index.html (or both), you are in the #51042 case and the host needs an explicit pointer.
Which one you get depends on your exact Next.js version and whether the 404 page is a custom component. Don't try to reason it out from the version number. Run the find and read the result. The failure mode that costs people an afternoon is assuming the page never generated. It generated; it's namespaced into a directory the host doesn't check.
One line of ErrorDocument in .htaccess
You already own the error-handling layer on cPanel: the .htaccess at your web root from Chapter 5. Apache's ErrorDocument directive tells the host which document to serve for a given status code, and it is valid in .htaccess. Point it at whichever 404 file your build emitted:
# Custom 404 page — without this Apache (cPanel) serves its own
# default "404 Not Found" page instead of the Next.js-rendered
# /404.html that ships in out/. Path is absolute from web root.
ErrorDocument 404 /404.html
On this site those four lines open public/.htaccess — three comment lines explaining the why, then the directive itself. That file ships into out/.htaccess at build and lands at the web root on deploy, so the directive goes live the moment the deploy completes. If your find showed out/404/index.html instead, point the directive there: ErrorDocument 404 /404/index.html. Same directive, whichever path your build produced.
The Apache core docs list three kinds of value for ErrorDocument, and the distinction matters for SEO. A local URL-path (begins with /, like /404.html) makes the host serve that file internally while still returning the true 404 status to the client. An external URL (http://…) makes Apache "send a redirect to the client," which means "the client will not receive the original error status code, but instead will receive a redirect status code." A redirect to a pretty 404 page would hand crawlers a 302 and let them treat the missing URL as if it had moved somewhere real. Use the local path so the response stays an honest 404.
The gotcha behind the gotcha
There is a second failure that looks identical to the first, and it bites you after everything above is correct. .htaccess is a dotfile, and most deploy tooling skips dot-prefixed files by default, silently. So you can have a flawless ErrorDocument line, deploy, and still land on the host's default 404 — because the .htaccess never left the build machine.
Closing that trap is exactly why Chapter 7 exists. It insists on carrying hidden files through the deploy because a stripped .htaccess takes your ErrorDocument, your redirects, and your RSC-payload rules down with it. Which is why the only test worth trusting exercises the full round-trip:
npm run build, thenfind out -iname '404*'to confirm the file exists and note its path.- Set
ErrorDocument 404 /<that-path>inpublic/.htaccess(which becomesout/.htaccess). - Deploy, confirming the
.htaccessactually reached the web root. - Visit a URL that does not exist. You should get your styled page with a genuine
404status.
If step 4 still shows the host default, leave your React alone. Re-check that the .htaccess shipped, then that the path in ErrorDocument matches what step 1 printed. It is one of those two, every time.
With the 404 handled on the host side, Chapter 7 is the deploy that keeps this .htaccess intact on the way up.
Wire a 404 contract check into your build
Run the ten-second diagnosis on your own export, point ErrorDocument at whichever path your build actually emitted, then go further than the chapter: add a check script that parses the ErrorDocument path out of your .htaccess and fails the build when that file is missing from out/, so the directive and the artefact can never silently drift apart.
Expected behaviour
- The find output recorded, showing whether your build emits 404.html, 404/index.html, or both
- ErrorDocument in public/.htaccess points at the exact path the find printed
- A check script extracts the ErrorDocument path from the .htaccess and exits non-zero when that file is absent from out/
- A live request to an invented URL returns your styled page with a genuine 404 status
PROVE IT Rename the 404 artefact inside out/ by hand and run the check script, capturing it failing with the mismatched path, then rebuild and capture the green run.
You deploy, a missing URL shows the host's grey default page, yet not-found.tsx renders fine in next dev. What actually went wrong?
Which vercel/next.js issue documents 404/index.html being emitted instead of a root 404.html under trailingSlash?
Your ErrorDocument line is correct and committed, yet the live site still shows the host default 404. What do you check before touching any React?
Show answer
First confirm the .htaccess actually reached the web root, because it is a dotfile and deploy tooling commonly skips dot-prefixed files silently. Then confirm the path in the ErrorDocument line matches what the find printed for your build's 404 artefact. The chapter's claim is that it is one of those two, every time.
↺ re-read: “The gotcha behind the gotcha”