404 on refresh: fix deep links in a single-page app on Vercel, Netlify and Cloudflare
A single-page app (React Router, Vue Router, most Lovable and Bolt apps) has one real file, index.html. Clicking a link inside the app works, but opening or refreshing /pricing asks the host for a file called /pricing, which doesn't exist, so the host answers 404.
The fix is one rewrite rule: serve index.html for any address that isn't a real file, and let the router show the right page.
Vercel
Vercel's Vite guide: "If your Vite app is configured to deploy as a Single Page Application (SPA), deep linking won't work out of the box." Its fix, a vercel.json at the project root:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
If cleanUrls is on, Vercel says to drop the extension (/index.html becomes /).
Netlify
Netlify's single-page app rule, in a _redirects file in your publish folder (for Vite, put it in public/):
/* /index.html 200
Or in netlify.toml:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
Netlify: "This will effectively serve the index.html instead of giving a 404 no matter what URL the browser requests." Real files still win, because "By default, you can't shadow a URL that actually exists within the site." Keep this rule last if you have others.
Cloudflare Workers static assets
In your Wrangler config:
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
Cloudflare: Workers "will serve the contents of the /index.html file with a 200 OK status" for requests that don't match a file.
Moving a Lovable app to Vercel or Netlify
Searches such as "lovable to vercel 404" show the usual moment: a project exported to GitHub and deployed on a new host, where inner routes 404 until the rule above is added. Add the file for your new host in the repo and redeploy.
Confirm it worked
Open a deep link such as https://www.example.com/pricing in a new tab and press refresh. It should show the page, and curl -sI https://www.example.com/pricing should answer 200. Give your app a "page not found" route for addresses it doesn't know, since the host now sends every path to the app.
What Pewterkit Check looks for
A free check picks up to three inner routes from your homepage's links and app code, opens each one directly the way a shared link or refresh would, and lists any that answer 404.
FAQ
Why does refreshing a React Router page give a 404?
The router runs in the browser. On refresh the browser asks the server for that path first, and without a rewrite there is no file there.
Will the rewrite break my images, CSS or API routes?
On Netlify, real files are served first by default, and Cloudflare's setting applies only to requests that don't match a file. After deploying, open an image, your CSS and an API route to confirm they still load. Test this on a preview deployment before relying on it.
Does a rewrite hurt SEO?
The rewrite returns your app with a 200, so links people share open the right page. Give unknown paths a clear "not found" page in the app.
Where does vercel.json go?
At the root of your project, next to package.json.
Sources
- Vercel: Vite on Vercel, using Vite to make SPAs — read 27 Oct 2026: deep linking, vercel.json rewrite, cleanUrls note
- Netlify: Rewrites and proxies, history pushState and single-page apps — read 27 Oct 2026: /* /index.html 200, shadowing
- Cloudflare Workers: Single Page Application routing — read 27 Oct 2026: not_found_handling
- Vite: Static Asset Handling, the public directory — read 27 Oct 2026: public files copied to the build root
How this page was made: drafted with AI from the sources listed, reviewed by Pewterkit. Docs checked Oct 27, 2026. Spotted something out of date? Tell us.
Related guides
- "Mixed content: the page was loaded over HTTPS, but requested an insecure resource": how to fix it
- Stripe secret key exposed in your frontend code: rotate it, then move it to the server
- OpenAI API key in your React or Vite frontend: rotate it and move calls to a backend
- Supabase service_role key in your frontend bundle: replace it and switch the old key off