The quick answer: does only a direct deep link fail?
If this is a plain Vite single-page app with browser-based routing, a missing server fallback is a plausible cause: the browser can change the view after loading the app, while a fresh request needs the host to deliver its entry HTML. Vercel documents a rewrite for that case. It is not the right first fix for every React project.
- Open the same deployment at
/, follow its own link to a real nested page, then refresh that page. - Record the failing URL, HTTP status and response body. Is it a Vercel error, an app-generated not-found view or a blank page?
- Check the project's actual framework mode and generated output. Apply the matching branch below, then validate in a preview before production.
This applies to apps actually hosted on Vercel, including exported AI-built apps. A Lovable or Bolt project hosted elsewhere needs that host's routing instructions.
Match the failure before changing vercel.json
| Observed symptom | Candidate boundary | Next check |
|---|---|---|
| Homepage works; clicking to /dashboard works; refreshing it fails | Possible missing SPA fallback | Identify the app mode and confirm the built entry file before editing routing. |
| Homepage and deep links both return 404 | Deployment, project root or output mismatch | Check the exact deployment URL, build output and framework preset first. |
| A page returns HTML, but stays blank | Script, asset or application failure | Inspect the first failed browser request and console error; a rewrite alone may not help. |
| API returns HTML or a JSON parse error | Request may be hitting the app shell | Inspect the API response and routing order; do not add another broad fallback. |
| Deployment URL works; custom domain fails | Domain assignment or proxy path differs | Compare the same path on both hosts and review domain/project mapping. |
The official 404 checklist starts with the deployment and output, not just rewrites. Compare its source revision, configured project root and produced files. In a monorepo, the repository root may not be the app root. A successful build is not proof that the intended files were published.
Why React Router works on click but not on refresh
A client-side transition can reuse the app already loaded from the homepage. A refresh or shared link starts with a new document request for the nested path. The host must reach the app's routing entry before the client router can select the screen.
A rewrite serves another destination without changing the visible URL. Redirecting every visitor to / instead can hide the failing path and lose the intended destination; it is not equivalent to preserving /dashboard.
Plain Vite SPA or React Router Framework mode?
Do not infer the deployment mode from “React Router” in a dependency list. Read the build script, framework configuration and the actual output:
- Plain Vite SPA: a static client build, commonly produced with
vite build, with a built entry HTML and browser routes. Use the scoped fallback section below. - React Router as a framework: follow the Vercel Framework integration and preset. Its supported SSR and SPA deployments are not the same setup as adding BrowserRouter to a Vite app. Inspect that integration before adding catch-all overrides.
- Static-hosted Framework SPA output: React Router's SPA guide describes
ssr: false, a generatedindex.htmland directing valid app paths to that entry. This does not mean an SSR application should have server rendering disabled to suppress a 404. - Next.js or another server/full-stack framework: inspect native routes and framework output. Do not replace the server route tree with a static index fallback.
Configure the fallback for a plain Vite SPA
Example scope: a static SPA with an emitted index.html, no server/API routing to preserve, and cleanUrls absent or false. Check the deployed entry exists first. The Vite guide provides this basic vercel.json pattern:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}Place the configuration at the actual app project root. If a file already exists, merge only the reviewed rule; do not replace existing headers, redirects, functions or other configuration. Have the project owner approve the change and test a new preview deployment.
If you have API endpoints, proxy rules, server-rendered routes or a different entry file, this is not a drop-in configuration. Preserve those destinations and check route precedence. An API that returns the app's HTML can look like a successful HTTP 200 while breaking the product.
If cleanUrls is true: do not blindly retain a destination ending in .html. Vercel's Vite instructions use the extensionless root entry / for that example. Review the effective static configuration and actual entry mapping together. Do not toggle cleanUrls merely to match a copied snippet.
If the fallback loads the shell but the route is still wrong, confirm the client router actually defines that path. If the page is blank, inspect the script and stylesheet responses: a JavaScript request receiving HTML points to a different routing or asset-path problem, not proof that another catch-all is needed.
Verify deep links, assets and APIs before calling it fixed
The following is our proposed acceptance checklist, not a completed Vercel test. Use a non-sensitive preview and an existing read-only page. Do not create customer records, send email or trigger payments to exercise routing.
- Direct entry: paste a known nested URL into a new tab. Confirm the intended screen, not just status 200 or the homepage.
- Refresh and history: reload the nested screen, then use back/forward. Keep the expected path and query parameters.
- Assets: inspect a real script and stylesheet request. Confirm their expected content types and bodies; HTML is not a successful script response.
- API boundaries, if present: use an authorized harmless read and confirm the expected format and authorization behavior, not the SPA shell. Never weaken authentication to pass this check.
- Unknown route: visit a made-up path. The app should explain that it does not exist. Check the HTTP status separately: a static fallback may return 200 with a client not-found screen, so this is not a universal search-indexing solution.
- Production host: after separate release approval, repeat the exact journey on the custom domain and record the deployed revision. A preview result is not production proof.
Deployment URL / source revision: Framework mode / build command / output directory: App root / config file / cleanUrls / entry file: Known nested path / click / direct load / refresh: Document status / actual screen / URL preserved: Script + stylesheet type / API response if applicable: Unknown path / visible not-found / HTTP status: Preview result / approved change / recovery owner: Production check after approval / remaining UNKNOWN:
Keep credentials, signed callback links and private log data out of shared records. If the suspected problem is a missing authentication callback, continue with the Supabase callback/session checks; a route that loads still needs its session logic verified.
What if the change makes things worse?
Preserve the prior configuration and deployment reference before editing. If the preview breaks previously working assets, APIs or routes, stop and revise the change instead of promoting it. For a production incident, review the Vercel rollback checklist; restoring code does not undo database changes.
For platform responsibilities, see the Vercel profile. For the broader handoff from an AI builder, use the export and migration checklist. This article helps isolate the request boundary; it does not establish your app's root cause or promise recovery.