Chapter 5 of 8. Prerequisite: Chapter 2 — you should already have
output: 'export'andtrailingSlash: trueset. This chapter is the.htaccessthat config demands, and the one rewrite rule you must not copy from a Stack Overflow answer.
Search "Next.js on shared hosting .htaccess" and the top answers all say the same thing: paste a single-page-app fallback that rewrites every unmatched request to index.html. For a client-routed SPA that is correct. For a trailingSlash static export it is wrong, and it fails in a way that looks like it is working, which is the worst kind of wrong.
I hit the same trap the macOS launchd article is about: a fix that is textbook-correct in isolation, applied to a condition it does not actually fit, so it produces a plausible result that is quietly incorrect. There, granting Full Disk Access to a shell script was the "obvious" fix and it changed nothing, because the script was not the thing being denied. Here, the SPA fallback is the "obvious" fix, and it takes a site that already routes correctly and breaks it. This chapter is the .htaccess that captainrandom.co.uk actually ships, plus a precise account of why the catch-all belongs nowhere near it.
Why the SPA "rewrite everything to index.html" is wrong for a trailingSlash static export
Start with what the export produced. In Chapter 2 you set trailingSlash: true, and the comment in this site's next.config.ts states the consequence plainly: "Trailing slash = clean URLs on cPanel static hosting (/writing/ not /writing)". Every route is emitted as its own directory with an index.html inside it:
out/
├── index.html
├── writing/
│ └── index.html
├── learning/
│ ├── index.html
│ └── static-next-cpanel/
│ └── index.html
└── 404.html
That structure already resolves deep links on its own. When a browser requests /writing/, Apache and LiteSpeed both apply the standard DirectoryIndex behaviour: the URL maps to the writing/ directory, and the server serves writing/index.html. No rewrite is involved. A hard refresh on /learning/static-next-cpanel/ does exactly the same thing. The static export and the trailing slash have between them already solved the problem the SPA fallback is meant to solve.
Now add the fallback anyway. The rule people paste looks like this:
# DO NOT do this on a trailingSlash static export
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
Read the Apache mod_rewrite documentation for what those two conditions mean: the rule fires only when the requested path is "not an existing file" (!-f) and "not an existing directory" (!-d). On a real static export those conditions catch far more than you intend. Your route directories exist, so /writing/ survives. The damage lands on the paths that do not resolve to a file or a directory, and your custom 404 is one of them. A request for a genuinely missing page is not a file and not a directory, so the fallback rewrites it to the homepage and returns 200 OK. Your 404 page never renders and search engines index a soft-404. You built a static, multi-page site and then bolted on the routing model of an app that does not exist.
The correct .htaccess for a trailingSlash static export contains no catch-all rewrite to index.html. It does three things the export cannot do for itself, and nothing else.
The rewrite rules a static export actually needs on LiteSpeed
First, confirm the platform. cPanel hosts frequently run LiteSpeed rather than Apache, and the reasonable worry is whether your Apache .htaccess still applies. It does. The LiteSpeed migration FAQ states it directly: "LiteSpeed Web Server is a complete drop-in replacement for Apache. No changes are required," and "LSWS understands Apache configuration and behaves as you would expect Apache to." It reads your .htaccess, applies RewriteEngine, RewriteCond, and RewriteRule, and honours ErrorDocument. The same doc is honest that "to maximize speed and performance, there are some minor differences in rewrite rules implementation between LSWS and Apache by design," so keep the rules plain. Everything below is standard Apache syntax that LiteSpeed applies unchanged.
Here is the .htaccess this site ships, trimmed to the routing essentials. The file lives at public/.htaccess in the repo, so next build copies it to out/.htaccess and it deploys to the web root.
# 1. Custom 404 — without this the host serves its own default page
# instead of the Next-rendered /404.html that ships in out/.
ErrorDocument 404 /404.html
# 2. www → apex 301 (SEO: consolidate onto one canonical host)
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.captainrandom\.co\.uk$ [NC]
RewriteRule ^(.*)$ https://captainrandom.co.uk/$1 [R=301,L]
Two directives, each doing one job the flat files cannot.
Take the 404 handler first. The static export writes a 404.html at the output root, and ErrorDocument 404 /404.html is what points the host at it. Without that line the host ignores the file and serves its own generic "Not Found" page for every missing URL. The Apache core documentation says ErrorDocument is valid "in .htaccess files when AllowOverride is set to FileInfo" and that the action can be a local URL-path beginning with / (here, /404.html). Keep it local. The same doc warns that pointing ErrorDocument at a remote URL makes the server "send a redirect to the client," so "the client will not receive the original error status code, but instead will receive a redirect status code." A local path preserves the real 404 status, which is what search engines and monitoring need to see. The next chapter covers a known upstream Next.js bug that can put the file at 404/index.html instead of 404.html under trailingSlash. Treat this line as necessary but read Chapter 6 before you trust it.
The second directive, RewriteRule ^(.*)$ https://captainrandom.co.uk/$1, redirects the www host to the apex, and unlike the SPA catch-all it earns its place. The mod_rewrite docs describe the shape exactly: RewriteCond "defines a rule condition" that must match for "the following rule" to run, and RewriteRule takes Pattern Substitution [flags]. The RewriteCond %{HTTP_HOST} gate means the RewriteRule fires only for requests to www.captainrandom.co.uk; the [R=301,L] flags issue a permanent redirect and stop processing. On this site that landed after an SEO audit found Google had picked the www host as canonical for the homepage and eight indexed posts, splitting link equity across two hosts. One RewriteCond plus one RewriteRule consolidated it. The redirect operates at the host level: it decides which hostname a request lives under, and it never touches which file serves a given path. That distinction is exactly what keeps it safe where the catch-all is not.
The one rewrite you might genuinely need: content payloads and content-types
There is a third block in this site's real .htaccess, and it is the counter-example that proves the rule. A trailingSlash static export writes an index.txt next to every index.html. Those .txt files are React Server Component payloads, fetched by next/link over XHR for client-side route transitions. They are never meant to be a page you land on. On this site, iOS Safari started hitting /index.txt directly and the host served it as text/plain, so the browser downloaded a JSON blob instead of rendering, which broke analytics downstream.
The fix needs mod_rewrite and content-type control together:
RewriteEngine On
# (1) A browser (Accept: text/html) that lands on index.txt → 302 to the parent dir
RewriteCond %{HTTP_ACCEPT} text/html
RewriteRule ^(.*/)?index\.txt$ /$1 [R=302,L]
# (2) Serve .txt as the RSC content-type, but keep robots.txt plain
<FilesMatch "\.txt$">
ForceType "text/x-component; charset=utf-8"
</FilesMatch>
<Files "robots.txt">
ForceType text/plain
</Files>
Two things about this reinforce the chapter's point. First, the RewriteCond %{HTTP_ACCEPT} text/html gate targets one precise condition — a real browser asking for an RSC payload — which is the discipline the SPA fallback lacks when it reaches for everything. A person hitting index.txt gets a 302 back to the readable page; next/link's XHR, which does not send Accept: text/html, is untouched and still gets its payload. Second, this is a real rewrite you may need, and it stays scoped to a file pattern rather than a wildcard. Every rewrite in a correct static-export .htaccess is narrow. The moment a rule reaches for "everything," you have imported the SPA model by accident.
One LiteSpeed caveat from the vendor docs is worth internalising. The migration FAQ notes that "FallbackResource is not supported by LSWS. Instead, you can use rewrite rules." If a tutorial suggests FallbackResource /index.html as a tidier SPA fallback, it will silently do nothing on LiteSpeed, and you do not want it anyway.
The check that catches the wrong fix
The launchd lesson was to verify against the actual failing condition, not the one you assume. Apply the same discipline here. After deploying, test three URLs by hand and inspect the returned status codes, because the rendered page alone will hide a soft-404:
- A real deep link:
curl -I https://yoursite/writing/should return200. This provesDirectoryIndexis resolving your route directories without any rewrite. - A genuinely missing URL:
curl -I https://yoursite/this-does-not-exist/must return404, not200. If it returns200with your homepage body, you have a catch-all fallback swallowing errors. Remove it. - A payload file:
curl -I https://yoursite/writing/index.txtshould show the content-type your rules set, and a browser request to the same URL should redirect to the page.
The second check is the whole chapter in one command. A trailingSlash static export routes correctly on its own. Your job in .htaccess is to add the 404 handler, add the host-canonicalisation redirect, scope any content-type fixes your export needs, and make sure a missing page still says it is missing. The next chapter picks up exactly where that 404 status leaves off: the Next.js export bug that can move your 404.html and make the ErrorDocument line point at nothing.
Ship a scoped .htaccess for your own domain
Write the .htaccess your own export actually needs and nothing more: an ErrorDocument pointing at your 404 artefact, a host-canonicalisation 301 for your own domain in whichever direction you choose, and zero rules that reach for every request. Then run the three-status check from outside the host.
Expected behaviour
- public/.htaccess exists in the repo and appears at out/.htaccess after a build
- curl -I on a real deep link returns 200 with no rewrite rule involved, proving DirectoryIndex resolves your route directories
- curl -I on an invented URL returns a genuine 404, never a 200 with the homepage body
- curl -I on your non-canonical hostname returns a 301 pointing at the canonical host
- No RewriteRule in the file matches every request; each rule is scoped to a host or a file pattern
PROVE IT Paste the three curl -I outputs alongside the final file, then add the SPA catch-all on a staging copy, re-run the missing-URL check, and show it flipping to a 200 soft-404 before you delete the rule.
On a trailingSlash static export, what does the pasted SPA fallback do to a request for a genuinely missing page?
Which directive does the LiteSpeed migration FAQ name as unsupported, telling you to use rewrite rules instead?
Why does the www-to-apex redirect earn its place in the file while the SPA catch-all does not?
Show answer
The redirect is gated by a RewriteCond on HTTP_HOST, so it fires only for requests to the www hostname, and it operates purely at host level: it decides which hostname a request lives under and never touches which file serves a path. The catch-all instead grabs every request that is not an existing file or directory, which swallows genuine 404s and serves the homepage with a 200. Narrow scope is exactly what keeps one safe and the other harmful.
↺ re-read: “The rewrite rules a static export actually needs on LiteSpeed”