EthioDeploy
DocsPricingDomainsBlogToolsChangelog

Blog/Fix deploy errors

404 on page refresh in a React or Vite SPA

BS
Brook SolomonPublished November 4, 2026
Share on XShare on LinkedIn
/* → index.html

THE SHORT ANSWER

A single-page app returns 404 on refresh because the browser asks the server for a path like /dashboard, and the server has no file at that path. Your router only exists in the JavaScript that index.html loads, so the server has to answer every unknown path with index.html and let the router take over. It works locally because Vite's dev server and vite preview already do that. In production you add one rewrite rule to your host: send every path that isn't a real file to /index.html.

Why does refreshing a page give a 404?

Clicking a link inside the app never asks the server for anything. React Router, or whichever router you use, changes the URL with the browser's History API and renders the new page in JavaScript. Refreshing the page, or opening /dashboard from a bookmark or a shared link, is different: the browser sends GET /dashboard to the server.

After vite build, the server has index.html, an assets/ folder and nothing called dashboard. A plain static server answers 404 before your JavaScript ever loads. The fix is to tell the server: if no file matches the path, serve index.html with status 200, and the router reads the URL and renders the right page.

It works on your machine because Vite's appType defaults to 'spa', which turns on that fallback in the dev server and in vite preview. Vite's docs also say vite preview is for checking the build locally and is not meant as a production server, so the fallback has to come from your host.

How do I fix it on my host?

Every host needs the same rule, written in its own config. Real files keep being served as they are:

Host or serverWhere the rule goesRule
Netlify_redirects in the publish folder (put it in public/)/* /index.html 200
Vercelvercel.json at the project rootA rewrite from /(.*) to /index.html
Cloudflare Workerswrangler.toml, under [assets]not_found_handling = "single-page-application"
Cloudflare PagesNothing to addOn by default when the build has no top-level 404.html
Firebase Hostingfirebase.jsonA rewrite from ** to /index.html
NginxThe site's location / blocktry_files $uri $uri/ /index.html;
ApacheVirtual host or .htaccessFallbackResource /index.html
CaddyThe site block in the Caddyfiletry_files {path} /index.html before file_server
ExpressAfter express.static and your API routesA catch-all route that sends index.html

The full config for the ones that take more than a line:

// vercel.json (Vercel's Vite docs: deep linking "won't work out of the box" without it)
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

# netlify.toml, if you prefer it to _redirects
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

# wrangler.toml (Cloudflare Workers static assets)
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
# Nginx
location / {
    root /var/www/app/dist;
    try_files $uri $uri/ /index.html;
}

# Apache (mod_dir)
FallbackResource /index.html

# Caddy
example.com {
	root /srv
	encode
	try_files {path} /index.html
	file_server
}

Serving the build from Express?

Serve the static files first, then your API, then the catch-all last, so it only answers paths nothing else matched. Express 5 changed the wildcard syntax: a bare * no longer works and the wildcard needs a name. /{*splat} also matches the root path:

import express from 'express'
import path from 'node:path'

const app = express()
const dist = path.resolve('dist')

app.use(express.static(dist))
app.use('/api', apiRouter)

// Express 5. On Express 4 the same route is app.get('*', ...)
app.get('/{*splat}', (req, res) => {
  res.sendFile(path.join(dist, 'index.html'))
})

app.listen(process.env.PORT || 3000, '0.0.0.0')

If app.get('*') throws a path-to-regexp error after you upgrade, this is the change that caused it.

Refresh now loads a blank page or a MIME type error?

Once the fallback works, a deep URL like /settings/profile can load an empty page, with this in the browser console:

Failed to load module script: Expected a JavaScript module script but the server
responded with a MIME type of "text/html".

The page asked for its JavaScript at the wrong path, and the fallback answered with index.html instead of a 404. This happens when Vite's base is ./ or empty. Vite documents those values for embedded deployments: asset links become relative, so on /settings/profile the browser looks for /settings/assets/index.js.

Leave base at its default of / for an app at the root of its domain. For an app under a sub-path, set it to that path, such as /app/, and give your router the same base path.

Doesn't the fallback hide real 404s?

Yes. With the rule in place, /no-such-page also returns index.html with status 200, so it is now the app's job to say a page doesn't exist. Add a catch-all route to your router that renders a Not found page, and keep two things out of the rewrite:

  • Your API. A typo in a fetch URL should get a JSON 404, not a page of HTML that fails to parse. Put the API under a prefix like /api and route it before the fallback. Caddy's pattern wraps the two in separate handle blocks.
  • Missing assets. An old tab asking for a JavaScript file from a previous deploy gets HTML back, which is the MIME type error above. Rules that check for a real file first (try_files, FallbackResource) still serve every file that exists.

Can I use HashRouter instead?

HashRouter keeps the route after a #, as in /#/dashboard. React Router's docs describe it as storing the location in the hash “so it is not sent to the server”, so every request is for / and no rewrite is needed. It works on hosts where you can't add one.

The cost is URLs with #/ in them and links that are harder to share cleanly. If your host lets you add a rewrite, use BrowserRouter and the rule above.

GitHub Pages is the common case with no rewrite setting. The usual workaround is to copy index.html to 404.html in the build, since GitHub Pages serves a custom 404.html for pages that don't exist. GitHub's docs describe it as an error page, so treat it as a workaround, not a rewrite.

What happens on EthioDeploy?

A Vite app with no server of its own is served by Caddy with the fallback already on, so refreshing /dashboard works with no config file. Files that exist, like everything under /assets, are served as they are.

If your project runs its own server, such as Express serving dist/, the server is yours and needs the catch-all route from the Express section. Bind it to 0.0.0.0 and the PORT variable, as in 502 Bad Gateway after deploy.

Questions

Vite's dev server and vite preview fall back to index.html for any path, because appType defaults to 'spa'. Most production hosts don't, so a refresh on /dashboard asks the server for a file that doesn't exist. Add a rewrite that sends every path that isn't a file to /index.html.

Add a vercel.json at the project root with a rewrite from /(.*) to /index.html. Vercel's Vite docs say deep linking in a Vite SPA won't work out of the box without it.

Add a file named _redirects to the publish folder (public/ in a Vite project, so it is copied into dist/) with the line /* /index.html 200. The 200 makes it a rewrite, not a redirect.

In the location / block, add try_files $uri $uri/ /index.html; so Nginx serves the file if it exists, then the directory, and otherwise index.html.

Express 5 requires wildcards to have a name. Use app.get('/*splat', ...), or app.get('/{*splat}', ...) to match the root path too.

Only if you can't add a rewrite on your host. HashRouter keeps the route after #, which isn't sent to the server, so it works anywhere, but every URL gets #/ in it.

Sources

Checked on November 4, 2026:

  • Vite: appType
  • Vite: base
  • Vite: Deploying a static site
  • React Router: Direct all URLs to index.html
  • React Router: HashRouter
  • Netlify: History pushState and single-page apps
  • Vercel: Using Vite to make SPAs
  • Cloudflare Workers: Single page application
  • Cloudflare Pages: SPA rendering
  • Nginx: try_files
  • Apache: FallbackResource
  • Caddy: Single-page apps
  • Express: Migrating to Express 5 (path syntax)
  • GitHub Pages: Custom 404 page

Push a repo. Get a URL.

  • Deploy a Lovable app
  • Deploy a Bolt app
  • Framework detection
  • Troubleshooting deploys
Start free

Join the EthioDeploy Telegram

Release notes as they ship, plus deploy help from the team and other developers.

Join on Telegram
BS
WRITTEN BYBrook SolomonFounder, EthioDeploy

Brook Solomon founded EthioDeploy and builds its deploy pipeline, build servers and AI coding agent. The posts here come from errors seen in real deploys on the platform.

All posts by Brook →
On this page
  • Why does refreshing a page give a 404?
  • How do I fix it on my host?
  • Serving the build from Express?
  • Refresh now loads a blank page or a MIME type error?
  • Doesn't the fallback hide real 404s?
  • Can I use HashRouter instead?
  • What happens on EthioDeploy?
  • Questions
  • Sources
0% read

Keep reading

  • heap limit

    Fix deploy errorsNov 3, 2026

    JavaScript heap out of memory during next build

    next build fails with "JavaScript heap out of memory" when the Node.js process running the build fills V8's old-space heap. The heap has a size limit of its own, separate from how much RAM the machine has. The quick fix is to raise that limit for the build only, with --max-old-space-size set below the memory the build machine actually has. The lasting fix is to find which build step uses the memory (bundling, TypeScript or static page generation) and turn down that step: source maps, the type check, or webpack's cache.

  • code=H10

    Fix deploy errorsNov 2, 2026

    Heroku H10 App crashed: causes and fixes

    Heroku logs H10 App crashed when a request reaches your app and every web dyno has crashed. H10 is the symptom, not the cause. The real error is in the app's own log lines just above the router line, and it is usually one of five things: the app didn't bind to $PORT within 60 seconds, the Procfile is wrong, a package was pruned with devDependencies, a config var or database is missing at boot, or the dyno ran out of memory.

  • VITE_*

    Fix deploy errorsOct 29, 2026

    Vite env variables undefined in production: causes and fixes

    A Vite env variable is undefined in production when the build never saw it. Vite replaces every import.meta.env.VITE_ reference with a fixed value while vite build runs, so a variable added to your server afterwards changes nothing until you build again. The other common causes are a name without the VITE_ prefix and code that reads process.env in the browser.

EthioDeploy

Agentic first development and deployment, started in Ethiopia, built for developers everywhere.

Telegram community
Product
PricingAI coding agentAgent mode docsDomainsCLIFree backend hostingHosting in EthiopiaChangelog
Deploy guides
Next.jsNode.jsPythonSvelteKitTanStack StartDjangoFastAPIFlaskExpressNestJSLaravelRailsGoTelegram botLovableBoltReplitv0Google AI StudioBase44Firebase StudioClaude Code & Cursor
Compare
vs Herokuvs Vercelvs Rendervs Railwayvs Fly.iovs NetlifyHeroku alternativesVercel alternativesNetlify alternativesRender alternativesRailway alternativesFly.io alternativesReplit alternatives
Resources
DocsBlogFree toolsAboutSupportContactllms.txt
© 2026 EthioDeployPay by card in USD or bank transfer in ETBTermsPrivacy