OxyPages

How To Fix A 404 On Refresh In A Single-Page App

How To Fix A 404 On Refresh In A Single-Page App

Why a single-page app throws a 404 on refresh, and the one-line fix for Netlify, Vercel, Cloudflare Pages, GitHub Pages, nginx and Apache, plus two traps.

The OxyPages Team · · 8 min read

Your app works. You click from the home page to /settings and it loads. Then you press refresh, or paste that link into a chat, and the host serves a 404 page.

Nothing is wrong with your code. A 404 on refresh is what happens when a single-page app meets a server that only knows about files. The router that handles /settings lives in your JavaScript, and on a fresh request the server answers before any JavaScript has run.

The fix is one line of config on almost every host, but the line is different on each, and the wrong version breaks your scripts instead of fixing your routes. I've put the right one for seven hosts in a table below.

First, the reason, because it takes thirty seconds and stops you reaching for the wrong fix. Then the table, then the two mistakes I see people make after they've "fixed" it. If you're still getting the app online in the first place, deploying a Vite app from its dist folder comes first.

TL;DR: A client-side router changes the address bar without asking the server. On refresh, the browser asks the server for that path, finds no file, and gets a 404. The fix is a fallback that serves index.html with a 200 for paths that don't match a file: a _redirects line on Netlify, a vercel.json rewrite on Vercel, nothing at all on Cloudflare Pages, try_files on nginx, and a switch or built-in fallback on some hosts. GitHub Pages has no rewrites, so you use a 404.html copy or a hash router.

Why Does A 404 On Refresh Happen?

It happens because your router never asks the server about in-app links, and a refresh does. When you click a link in a React, Vue, Svelte or Angular app, the router calls the browser's History API to change the address bar and swaps the component on screen. No request leaves your machine.

MDN on the History API's pushState method explains the mechanism: the URL changes, but the browser doesn't load anything.

A refresh is different. The browser makes a real request for /settings. Your build has one HTML file, index.html, and no file called settings. So the server does what servers do with a path that matches nothing, and returns a 404.

I reproduced it on a fresh Vite build by serving the dist folder with a plain file server:

GET /                  200
GET /settings          404
GET /assets/nope.js    404

Then I ran the same build through vite preview:

GET /                  200  text/html
GET /settings          200  text/html
GET /assets/nope.js    200  text/html

That second result is why this bug only shows up after deploy. Vite's preview server falls back to index.html for every miss, so locally everything works. The real host doesn't, until you tell it to.

What Does The Fix Actually Do?

What Does The Fix Actually Do? - OxyPages

The fix is a fallback rule: when a request matches no file, serve index.html with a 200 status and leave the URL alone. The browser loads your app, the router reads /settings from the address bar, and it renders the right screen.

Four details separate a good fallback from a broken one:

  • It's a rewrite, not a redirect. A redirect sends the visitor to / and the original path is lost. A rewrite serves index.html while the address bar still says /settings.
  • It returns 200, not 404. Search engines and link previews treat a 404 as a missing page, even if your app renders fine on top of it.
  • Real files still win. /assets/index-DSXtc1GT.js must still serve the JavaScript. A rule that rewrites everything, files included, sends HTML where the browser expected a script.
  • Missing files should still 404. Ideally a missing .js or .png gets a real 404, not your HTML page. More on that below.

The Fix For A 404 On Refresh, Host By Host

Here's the one-line version for each host. The Netlify, Vercel, Cloudflare and GitHub rows are checked against their own docs as of November 2026.

HostWhat to addWhere it goes
Netlify/* /index.html 200A _redirects file in your build output (put it in public so Vite copies it)
VercelA rewrites entry sending /(.*) to /index.htmlvercel.json at the project root
Cloudflare PagesNothing. Leave out a top-level 404.htmlPages then treats the project as a single-page app
Cloudflare Workers (static assets)not_found_handling = "single-page-application"The assets block of your Wrangler config
GitHub PagesA copy of index.html named 404.htmlThe root of the published site
nginxtry_files $uri $uri/ /index.html;The location / block
ApacheFallbackResource /index.html.htaccess or the site config
OxyPagesSingle-Page App Mode for uploads; already on for Vite builds and app dropsThe project's Settings

Netlify

Netlify's docs give the rule as /* /index.html 200 and say it's "typically the last rule listed". It's safe because Netlify won't rewrite a path where a real file exists unless you add ! after the status. Put _redirects in your public folder so it lands at the top of dist.

Vercel

Vercel's Vite page says deep linking "won't work out of the box" for a single-page app and gives this file:

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Save it as vercel.json in the project root, not in public.

Cloudflare Pages

You don't add anything. Cloudflare's docs say that if your project has no top-level 404.html, Pages assumes a single-page app and serves the root for any path that isn't a static asset. If you do ship a 404.html, that behaviour switches off. Cloudflare's docs on how Pages serves files

If you deploy to Cloudflare Workers with static assets instead of Pages, the fallback is opt-in: set not_found_handling to "single-page-application" in the assets config.

Don't add a Netlify-style catch-all to _redirects here. Cloudflare's docs say its redirects are followed "regardless of whether or not an asset matches", so a catch-all can catch your scripts too.

GitHub Pages

GitHub Pages has no rewrite rules at all. The usual workaround is to publish a copy of index.html as 404.html. GitHub serves it for any missing path, the URL stays intact, and your router takes over.

It works, with one cost: every deep link returns a 404 status. Visitors won't notice, but crawlers will, so it's fine for an internal tool and poor for pages you want indexed. The other option is a hash router, which I cover in the FAQ.

One more GitHub trap: a project site lives at username.github.io/repo-name/, not at the root, so you also need to set Vite's base path or the page loads blank.

OxyPages

On a plain HTML project, it's a switch called Single-Page App Mode in the project's Settings. A Vite or other single-page app built from a repository, or sent as an app drop, has the fallback on already, so add nothing.

Moving from Netlify? A copied /* /index.html 200 line won't break anything, because rules follow Netlify's shadowing behaviour and real files still win. It's just redundant on a Vite build, where the fallback is already on.

Two Mistakes After The Fix

Two Mistakes After The Fix - OxyPages

Most people stop once refresh works. Two problems tend to show up a week later.

The Fallback Catches Missing Scripts

A catch-all that serves index.html for every miss also serves it for a missing .js file. You see this right after a redeploy: someone has the old page open, it asks for index-OLDHASH.js, that file is gone, and the browser gets HTML back.

The console says Unexpected token '<', which is the first character of <!doctype html>. The app looks dead until they reload.

The cleanest fallback only applies to paths with no file extension, so /settings gets the app and /assets/old.js gets an honest 404. nginx and Apache let you write it that way. Some hosts already work like this, answering only extensionless paths and letting missing images and scripts 404.

Real 404s Disappear

Once every path returns your app with a 200, a mistyped URL like /setings returns 200 too. Your router should have a catch-all route that renders a proper "page not found" screen, so visitors aren't left staring at an empty layout. It's worth reading what makes a good custom 404 page before you design that screen.

The status code will still say 200 on a static host, because the server can't know which routes your app recognises. If that matters for SEO, prerender your real routes into their own HTML files, which frameworks like Astro and SvelteKit do by default.

Walkthrough: Fixing A React Router App On Netlify

Here's the whole fix for a Vite app using React Router, deployed by dragging dist onto Netlify:

  1. Confirm the bug. Open the live site, click to /settings, refresh. You get Netlify's "Page not found".
  2. Create a file called _redirects in the public folder. Put one line in it: /* /index.html 200.
  3. Run npm run build and check that dist/_redirects exists. Vite copies everything in public across untouched.
  4. Drag the new dist folder back onto the site's deploys page.
  5. Refresh /settings. It loads.
  6. Open /assets/does-not-exist.js in a tab. Netlify's file-first rule means you get the app here, not a 404, which is why your router needs its own not-found route.

The whole thing takes about five minutes, most of it waiting for the upload.

What To Do Next

  1. Check whether your app uses a history router (React Router's BrowserRouter, Vue's createWebHistory, Angular's default). If it does, you need the fallback.
  2. Find your host in the table and add the one line.
  3. Rebuild, redeploy, then refresh on a deep route and paste one into a private window.
  4. Add a not-found route in your router so typos get a real message.
  5. If you're choosing a host anyway, compare the React hosting options with routing in mind.

If you'd rather not touch config files at all, publish your app with OxyPages, where the fallback is one switch on a plain upload and already on for Vite builds. For a portfolio, a demo or a personal project, start a free account, no card needed. It only serves files, so an app that needs server-side code belongs on a host that runs it.

FAQ

Why Does My Single-Page App Work Locally But Show A 404 On Refresh When Deployed?

Because the dev server and vite preview both fall back to index.html for every path, and your host doesn't until you configure it. Locally the problem is hidden. Add the fallback rule for your host and the deployed site behaves the same way.

Can I Use A Hash Router Instead?

Yes. With a hash router your URLs look like example.com/#/settings, and the part after # never reaches the server, so every request is for / and nothing 404s. It works on any host with no config. The cost is uglier URLs that search engines treat as one page.

Will A Redirect To The Home Page Fix It?

No. A redirect changes the URL to /, so the router never sees /settings and the visitor lands on the home screen instead of the page they asked for. You need a rewrite, which serves index.html while keeping the original address.

Does A Next.js Static Export Need This Fallback?

Usually not. A static export writes a separate HTML file for each route, like about.html, so the host has a real file to serve. If you do hit a 404 on refresh with an export, it's usually a dynamic route you didn't prerender, or a host that won't serve about.html for /about.

Try It Now

Still Here? Drop It In.

The whole pitch fits in one sentence: your HTML, on a link, in seconds.

Drag and drop your HTML files, a folder, a ZIP, your app's build folder, a PDF or an image

or ·

No account needed. Your unclaimed website stays live for 30 minutes on a free subdomain. Claim it to your account to keep it permanently.

  • No Account Needed
  • Free SSL
  • 30-Minute Unclaimed Link, Claim To Keep It