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
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.
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:
GET /dashboard HTTP/1.1
Host: example.comA static host maps URLs to files. There is no dashboard file or folder in your build output, so it answers:
404 Not FoundThe 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:
- If the request matches a real file (a script, a stylesheet, an image), serve it.
- Otherwise serve
index.htmland let the client router decide.
Here is that rule for common servers.
nginx#
Use try_files in the location / block:
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:
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:
<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/):
/* /index.html 200The 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):
{
"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:
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/. Setbasein Vite to match. - Relative asset URLs on deep routes. On
/users/42, asrc="assets/app.js"resolves to/users/assets/app.js. Use absolute paths starting with/. - Stale deploy. The cached
index.htmlpoints to hashed files from the previous build that no longer exist. Upload the new files together with the newindex.html, and avoid long caching onindex.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:
// Vue Router
{ path: "/:pathMatch(.*)", component: NotFound }// 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
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.
More in Websites
See all 20 articles- What is a static site (and how to host one)A static site is a set of ready-made HTML, CSS and JavaScript files served the same to every visitor. Learn how it works, when to use one and how to host it.
- How much does it cost to host a website?Hosting a website costs anywhere from nothing to a few dozen dollars a month. See what goes into the bill: hosting, domain, traffic and extras, with real limits.
- How much does static site hosting cost?Hosting a static site costs between zero and a few dozen dollars a month in most cases. See how traffic sets the price, with step-by-step math.
- How much does it cost to host a Next.js app?Hosting a Next.js app costs from zero with a static export to tens of dollars a month with a server. See the billing models, traffic math and hidden costs.