Fix Next.js Static Export URLs Returning 404 on Refresh

4 min read.
Tags: Next.js

Your Next.js static export works when you click around, but opening /about directly or refreshing the page returns a 404. The cause is the file layout Next.js writes, and the fix is one line in next.config.js.

Quick fix

// next.config.js
module.exports = {
  output: 'export',
  trailingSlash: true,
}

Rebuild, deploy the new out/ folder, and /about works on any static file server.

Why the URL returns 404

By default, the export writes one HTML file per route, named after the route:

out/
├── 404.html
├── about.html
├── blog/
│   └── hello.html
└── index.html

Clicking a <Link> works because the Next.js router navigates in the browser. It fetches the route's data file (about.txt) instead of loading /about as a page. A direct visit or a refresh sends a real request for /about. A plain static server looks for a file or folder called about, finds neither, and returns 404. It doesn't know that /about should mean about.html.

Here's the default export served with python3 -m http.server:

/              200
/about         404
/about/        404
/about.html    200
/blog/hello    404

What trailingSlash: true changes

With trailingSlash: true, each route gets its own folder with an index.html inside:

out/
├── 404.html
├── 404/
│   └── index.html
├── about/
│   └── index.html
├── blog/
│   └── hello/
│       └── index.html
└── index.html

Every static server knows to serve index.html for a folder, so the same Python server now returns:

/              200
/about         301  → /about/
/about/        200
/blog/hello    301  → /blog/hello/
/blog/hello/   200

The server redirects /about to /about/ and then serves the page. <Link> also renders href="/about/", so internal links point straight at the canonical URL without the redirect.

This blog runs as a Next.js static export with trailingSlash: true for the same reason.

Fix it on the server instead

If you want to keep URLs without a trailing slash, leave the config alone and teach the server to try .html. On nginx:

location / {
  try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html;

Against the default export:

            plain nginx   with try_files
/about      404           200
/blog/hello 404           200
/nope       404           404

/about/ with a slash still returns 404 here, because there's no about/ folder. If people link to your site with trailing slashes, trailingSlash: true is the simpler fix.

On S3 or another bucket with no rewrite rules, you can't do this, so use trailingSlash: true.

Why it works locally and breaks in production

npx serve out maps /about to about.html on its own. Against the default export it returns 200 for /about, /about/, and /blog/hello, so the bug never shows up when you test locally. Some hosts do the same mapping and others don't. Before you deploy somewhere new, test with a server that doesn't, like python3 -m http.server from inside out/.

next export is gone

Older answers tell you to run next build && next export. In current Next.js, that command fails:

`next export` has been removed in favor of 'output: export' in next.config.js.

Set output: 'export' in the config and run next build. The export lands in out/.

Common mistakes

Deploying the old out/ folder. trailingSlash changes the files on disk, so you have to rebuild. Check that out/about/index.html exists before you upload.

Hardcoding links without the slash. A plain <a href="/about"> still works through the redirect, but every click costs an extra round trip. Use <Link>, or write the slash yourself.

Changing the setting on a live site without redirects. Switching to trailingSlash: true changes every canonical URL from /about to /about/. Search engines treat those as different pages, so make sure the old URLs redirect (static servers usually do this for folders, as shown above) and update your sitemap.

Conclusion

A Next.js static export returns 404 on direct visits because /about is saved as about.html and static servers don't add the extension. Set trailingSlash: true so each route becomes about/index.html, or add try_files $uri $uri.html on nginx if you control the server.

References