Fixing 404s on Refresh in Next.js Static Export
The symptom is unmistakable: every page on a Next.js static export works when you click through to it from the home page, and then returns 404 when you press refresh, open it in a new tab, or follow a link from another site. Search engines see the 404s too. The cause is that client-side navigation never asks the host for the page's HTML file; a refresh does. If the exported file is not where the host looks for it, the host has nothing to serve.
This guide fixes the problem on a Next.js 15 App Router site exported with output: 'export' and deployed in three places during a hosting change — an S3 bucket behind CloudFront, an Nginx server, and Cloudflare — because the fix depends on the host. It belongs to Next.js Static Export for Content Sites, and complements Trailing Slash and basePath in Next.js Static Export.
Why It Works on Click and Fails on Refresh
By default, next build with output: 'export' writes out/docs/install.html. A host that maps /docs/install to docs/install.html serves it. A host that only maps /docs/install/ to docs/install/index.html — the convention of plain web servers and object stores — does not.
Step 1: Confirm Which File the Export Wrote
ls out/docs/ | head
# install.html install.txt configure.html configure.txt
curl -sI https://staging.example.com/docs/install | head -1
curl -sI https://staging.example.com/docs/install.html | head -1
If the .html URL returns 200 and the extensionless one 404, the host has no clean-URL mapping. If both 404, the page was not exported at all — see Cause 3.
Cause 1: The Host Has No Clean URLs (S3, Plain Nginx)
Fix: make the export write directory indexes, which every host understands:
// next.config.mjs
export default { output: 'export', trailingSlash: true };
Now the export writes out/docs/install/index.html, and links render as /docs/install/. Plain S3 website hosting and Nginx's default try_files $uri $uri/ =404 both serve it.
For S3 behind CloudFront with the REST endpoint (not the website endpoint), index documents are not resolved for subdirectories. A CloudFront Function appends index.html to directory requests, as covered in Clean URLs and Trailing Slashes on S3:
function handler(event) {
var r = event.request;
if (r.uri.endsWith('/')) r.uri += 'index.html';
else if (!r.uri.includes('.')) r.uri += '/index.html';
return r;
}
Cause 2: Nginx Without the Right try_files
Keeping trailingSlash: false on Nginx works too, if Nginx tries the .html file:
location / {
try_files $uri $uri.html $uri/index.html =404;
}
error_page 404 /404.html;
The original config had try_files $uri $uri/ /index.html; copied from a single-page-app tutorial. That caught every unknown URL and served the home page's HTML instead of a 404 — the "wrong content on refresh" variant of this bug, where search engines index dozens of URLs with the home page's content.
Cause 3: Dynamic Routes Not Generated
A refresh on /blog/2026-release-notes 404'd on every host. The file did not exist: the route app/blog/[slug]/page.tsx generated its params from a CMS query that returned only the first 100 posts — the CMS API's default page size. Client navigation to the 101st post worked because the link fetched its payload… which also did not exist, but the old client cache from a previous visit masked it during testing.
export async function generateStaticParams() {
const posts = await getAllPosts(); // now paginates through every page of the API
return posts.map((p) => ({ slug: p.slug }));
}
export const dynamicParams = false;
dynamicParams = false makes the build fail on routes requested but not generated during development, rather than silently skipping them. A post-build check compares the number of out/blog/*/index.html files against the CMS's total count.
Cause 4: A Catch-All Rewrite on the Host
Cloudflare served the site correctly except for one pattern: a _redirects rule /* /index.html 200, added months earlier for a now-removed client-side app section. It rewrote any path without a file to the home page. Removing it fixed the remaining "wrong content" reports, and the custom 404.html exported by Next.js (from app/not-found.tsx) was then served with a real 404 status.
Step 2: Test Direct Loads in CI
The bug survives because developers navigate by clicking. A Playwright test now loads a sample of URLs directly, by host configuration, in a fresh browser context:
for (const path of samplePaths) {
const res = await page.goto(baseURL + path);
expect(res.status(), path).toBe(200);
await expect(page.locator('h1')).not.toHaveText('Welcome to Acme', { timeout: 1000 });
}
The second assertion catches the catch-all rewrite variant, where the status is 200 but the content is the home page. Sample paths include the deepest generated route of each dynamic segment and the last item of each paginated source.
basePath and Asset Paths on Subpath Deploys
One more variant appeared when the docs were also deployed under /docs-preview/ on a shared domain for review. Pages loaded on refresh, but every script and stylesheet 404'd, so the page rendered unstyled and never hydrated — which looks like a different bug but has the same root: the host serves files by path, and the paths in the HTML did not match where the files were. Setting basePath: '/docs-preview' for that build (from an environment variable) prefixed both page links and _next/ asset URLs. assetPrefix alone, which some guides suggest, prefixes assets but not links, and left client navigation pointing at the root.
The general rule for this whole class of bug: when a static export misbehaves only on direct loads, compare three things for one failing URL — the path the browser requested, the file the export wrote, and the rule the host used to map one to the other. One of the three is always wrong, and curl -I plus ls out/ usually shows which in under a minute. On this site the investigation notes for all four causes fit in a short table in the repository's README, which has since saved two other developers from rediscovering them after unrelated hosting changes.
Custom 404 Pages
Once real 404s return 404, the page they show matters. The export writes out/404.html from app/not-found.tsx; each host needs pointing at it. Cloudflare and Netlify use 404.html at the root automatically. Nginx needs error_page 404 /404.html; (shown above). S3 with CloudFront needs a custom error response mapping 404 to /404.html with status 404 — not 200, which some tutorials use for single-page apps and which recreates the soft-404 problem.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| 404s on direct load, 60 sampled paths (S3) | 58 | 0 | Playwright |
| Blog posts exported | 100 of 384 | 384 | find out/blog |
| URLs serving home page content | 31 (Cloudflare) | 0 | Playwright content check |
| "Not found (404)" in Search Console | 212 | 4 (real) | Coverage report |
| Search clicks to deep pages, 4 weeks | baseline | +19% | Search Console |
Pitfalls & Rollback
- Testing only by clicking. Load URLs directly, in a clean context.
- Copying SPA rewrite rules. A static export is a multi-page site; remove catch-all rewrites.
- Unpaginated data sources in
generateStaticParams. Fetch every page; setdynamicParams = false. - Changing
trailingSlashwithout redirects. URLs change from/ato/a/; most hosts redirect automatically, but check. - Rollback:
trailingSlashand host rules are independent switches; revert either alone.
Conclusion
A Next.js static export that 404s on refresh is a mismatch between the files the export writes and the files the host looks for, plus, sometimes, routes that were never generated or a leftover catch-all rewrite. Setting trailingSlash: true for simple hosts, fixing try_files or adding a CloudFront Function, generating every dynamic route, and testing direct loads in CI removed 212 Search Console 404s and made every page reachable by URL.
FAQ
Why does a Next.js static export page work when clicked but 404 on refresh?
Client-side navigation never asks the server for the page's HTML file; the router fetches data and renders in the browser. A refresh or direct visit asks the host for the URL, and if no file exists at the path the host looks for, it returns 404. The page works in one case and not the other because only the second depends on how files are laid out and how the host maps URLs to files.
Should I use trailingSlash true in a static export?
It makes the export write about slash index.html instead of about.html, which every static host serves at slash about slash without configuration. It is the safest default for hosts without clean URL support, such as plain S3 or simple Nginx configs. Hosts with clean URLs, like Cloudflare, Netlify and Vercel, work with either setting.
How do I handle dynamic routes that were not generated at build time?
They cannot exist in a static export. Every dynamic route must be listed by generateStaticParams. For paths that genuinely cannot be known at build time, render a client-side shell at a fixed path and rewrite the dynamic path to it at the host, accepting that the content loads in the browser.
Why do refreshed pages show the wrong content instead of a 404?
Usually because the host has a catch-all rewrite to index.html, a single-page-app setting. Every unknown path then serves the home page's HTML, which hydrates and may briefly or permanently show the home page. Remove the catch-all for a multi-page static export and let real 404s return 404.
Related
- Parent: Next.js Static Export for Content Sites — running Next.js as a static generator.
- Trailing Slash and basePath in Next.js Static Export — URL settings in depth.
- Handling Dynamic Routes in Next.js Static Export — generating every route.
- Serving a Static Site with Nginx — the Nginx config used here.
- Fixing Soft 404s on Static Hosts — the catch-all variant from a search perspective.