404 on refresh in a React/Vue SPA after deploy

The server looks for a file at the URL you refreshed, finds none and returns 404. Fix it by making the server answer unknown paths with index.html, so the client router can take over.

Updated 5 min read

Learning objectives

After reading this article you will be able to:

  • Understand why a refresh breaks routes that worked while navigating
  • Apply the index.html fallback in nginx, Express, Apache or Netlify
  • Keep real 404 pages and missing assets from being hidden
On this page

You deploy your React or Vue app. The home page loads, the links work, and then you refresh on /dashboard or paste a deep link and get a 404 from the server. It never happened locally.

The app is fine. The server just doesn't know that /dashboard is a screen in your JavaScript, not a file. Below are the mechanism and the fix for the most common servers.

CLICK A LINKRELOAD THE PAGEBrowserRouterswaps the screen/ → /dashboardServergets nothingBrowserreloads /dashboardGET /dashboardServerlooks for the file404no fallbackindex.htmlwith fallback
Clicking a link never reaches the server; a reload does, and without a fallback to index.html it answers 404.

Why does a refresh return 404 when navigation works?#

A single-page application (SPA) ships one HTML file, usually index.html, plus JavaScript bundles. When you click a link, the router (React Router, Vue Router) calls the History API and swaps the view. The URL changes, but the browser sends no request.

A refresh is different. The browser asks the server for exactly that path:

http
GET /dashboard HTTP/1.1
Host: example.com

A static host maps URLs to files. There is no dashboard file or folder in your build output, so it answers:

text
404 Not Found

The development server hides this because it already falls back to index.html for unknown paths. The Vite docs also note that vite preview is only for previewing the build locally and is not meant as a production server, so a working preview proves little about your host.

How do I fix it on the server?#

Configure the server to return index.html for any path that is not an existing file. Then the browser loads the app, the router reads the URL and renders the right screen. The Vue Router docs describe this as the required server setup for history mode.

The rule has two parts, in this order:

  1. If the request matches a real file (a script, a stylesheet, an image), serve it.
  2. Otherwise serve index.html and let the client router decide.

Here is that rule for common servers.

nginx#

Use try_files in the location / block:

nginx
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }
}

nginx tries the file, then the directory. If neither exists, the last parameter applies, and here it is an internal redirect to /index.html. Reload nginx after editing.

Express or another Node.js server#

Serve the build folder, then add a catch-all route that sends index.html. This example uses Express 5, which requires a named wildcard:

js
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";

const dist = path.join(path.dirname(fileURLToPath(import.meta.url)), "dist");
const app = express();

app.use(express.static(dist));
app.get("/{*splat}", (req, res) => {
  res.sendFile(path.join(dist, "index.html"));
});

app.listen(process.env.PORT || 3000);

In Express 4 the catch-all is app.get("*", ...). In Express 5 the wildcard must be named, and /{*splat} also matches the root path, as the Express migration guide explains. Register the catch-all after express.static and your API routes, or it will swallow them.

Apache#

Put this in an .htaccess file next to index.html, with mod_rewrite enabled:

apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>

The two conditions skip existing files and directories, so only unknown paths fall back.

Netlify and Firebase Hosting#

On Netlify, add a _redirects file to the published folder (for a Vite app, put it in public/ so it is copied to dist/):

text
/*  /index.html  200

The 200 makes it a rewrite, not a redirect, so the URL stays the same. By default Netlify does not shadow URLs that match a real file, so assets are still served normally.

On Firebase Hosting, add a rewrite in firebase.json (files that exist are still served first):

json
{
  "hosting": {
    "public": "dist",
    "rewrites": [
      { "source": "**", "destination": "/index.html" }
    ]
  }
}

Other platforms have an equivalent setting, often called "SPA mode", "rewrites" or "fallback". Search your host's docs for those words.

Why do I now get a blank page or a MIME type error?#

A fallback that is too broad causes the opposite problem. If the browser requests /assets/app.abc123.js and the file is missing, the server returns index.html with status 200, and the browser receives HTML where it expected JavaScript. With module scripts, which is what Vite emits, Chrome reports the first error below; with classic scripts, the second:

text
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html".
Uncaught SyntaxError: Unexpected token '<'

Common causes:

  • Wrong base path. The app is served from a sub-path (/app/) but built for /. Set base in Vite to match.
  • Relative asset URLs on deep routes. On /users/42, a src="assets/app.js" resolves to /users/assets/app.js. Use absolute paths starting with /.
  • Stale deploy. The cached index.html points to hashed files from the previous build that no longer exist. Upload the new files together with the new index.html, and avoid long caching on index.html.

Open the Network tab and check the failing request. If a .js file comes back as text/html, the fallback is catching a missing asset.

How do I keep a real 404 page?#

With a blanket fallback the server never returns 404 for pages. The Vue Router docs point out this consequence and suggest a catch-all route inside the app:

js
// Vue Router
{ path: "/:pathMatch(.*)", component: NotFound }
jsx
// React Router
<Route path="*" element={<NotFound />} />

Users see a not-found screen, but the server still answers 200, and search engines may treat these pages as soft 404s. If that matters, add a noindex meta tag on the not-found screen, or move to server-side rendering or prerendering so the server can return a real 404. See SSR vs SSG vs static for the trade-offs.

What about HashRouter?#

HashRouter (React) and createWebHashHistory (Vue) put the route after #, as in /#/dashboard. Browsers never send the fragment to the server, so every refresh requests / and no server config is needed. It is a legitimate option for hosts where you cannot add rewrites. The downside is that URLs look worse and the server cannot see or index individual routes.

How to do this on Square Cloud#

Square Cloud is self-service hosting, so you decide how the build is served. On the static sites runtime, the fallback is one step: rename the build's index.html to vite.html. The runtime answers every path that is not a file with vite.html, so any deep link loads the app. Without vite.html, unknown paths get your 404.html.

If you prefer to control routing yourself, use the Node.js runtime with an Express server like the one above (with express in your package.json). Both options are in the documentation's React tutorial. A web application needs at least 512 MB of RAM from your plan's shared pool and gets an HTTPS *.squareweb.app subdomain. You can deploy by ZIP upload in the dashboard, with the CLI, or from GitHub.

After deploying, open a deep link directly and refresh it to confirm the fix. The full path from build to live URL is in how to host a React app, and the hosting features for sites are on the Sites page.

Website hosting

Static and SSR sites with automatic TLS and a global CDN

Learn more

FAQs

Why does my SPA work on the home page but 404 on refresh?

Clicking a link changes the URL in the browser without a request, so the router handles it. A refresh sends a real request for that path, and a static server has no file there.

Does HashRouter fix it?

Yes. The route lives after the # in the URL, which the browser never sends to the server, so every refresh requests the same index.html. The cost is uglier URLs, and the server cannot see the route.

Should the fallback return status 200 or 404?

For a client-rendered SPA, 200 with index.html, because the app decides what to show. Render a not-found screen inside the app for unknown routes.

Why do I get a blank page or a MIME type error instead of a 404?

A blanket fallback also answers requests for missing JavaScript or CSS files with index.html. The browser receives HTML where it expected a script. Check the asset paths and the base setting.

Next articleWebsite hosting vs app hosting (Node, Python, bots)Website hosting serves ready-made files. App hosting keeps a process running. See the difference, a side-by-side table and which one to pick for each project.

More in Websites

See all 20 articles