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

Client-side navigation compared with a direct load of the same URL Three actors, the browser, the Next.js router in the page and the static host. On a click, the router intercepts the link, fetches the RSC payload file for docs install from the host, and renders without requesting HTML. On a refresh, the browser requests slash docs slash install from the host directly. The host looks for a file at that path, finds only docs slash install dot html, and returns 404. Two ways to reach /docs/install Browser Next.js router Static host click <Link href="/docs/install"> GET /docs/install.txt (payload) 200 refresh: GET /docs/install 404: no file at that path
The click path requests docs/install.txt; the refresh path requests the HTML at a path the host must map to a file.

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.

Configuration needed per host for each trailing slash setting A matrix of four hosts. Cloudflare serves both settings without extra configuration. Netlify serves both without extra configuration. Nginx needs try_files with the html extension for trailingSlash false and works by default for trailingSlash true. S3 with CloudFront needs a CloudFront Function for either setting, simplest with trailingSlash true. What each host needs Host trailingSlash: false trailingSlash: true Cloudflare works works Netlify works works Nginx try_files $uri.html works by default S3 + CloudFront function needed function for index.html
trailingSlash true is the setting that works on the most hosts with the least configuration.

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.

Steps of the direct-load test against a preview deploy Four steps. Build the export and deploy a preview. Pick sample paths including the last item of each dynamic route. Load each path directly in a fresh browser context, not by clicking. Fail on any non-200 status or on content matching the home page. Testing what a refresh sees Preview deploy same host config as prod Sample paths last item per route Direct load fresh context, no clicks Assert 200 and not home content
Runs in about 20 seconds on 60 sampled paths per deploy.

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

MeasureBeforeAfterTool
404s on direct load, 60 sampled paths (S3)580Playwright
Blog posts exported100 of 384384find out/blog
URLs serving home page content31 (Cloudflare)0Playwright content check
"Not found (404)" in Search Console2124 (real)Coverage report
Search clicks to deep pages, 4 weeksbaseline+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; set dynamicParams = false.
  • Changing trailingSlash without redirects. URLs change from /a to /a/; most hosts redirect automatically, but check.
  • Rollback: trailingSlash and 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.

Filed underFixesNext.js