Why it happens
Clicking a link inside the app works because React Router changes the URL without asking the server for anything. Opening the same URL in a new tab, refreshing it, or arriving from Google asks the server for /contact, and there is no file at that path in the build output.
The fix
Add a catch-all rewrite to vercel.json so any path without a matching file is served the app shell, which then renders the right route.
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}Static files such as /sitemap.xml, /robots.txt and /assets/* keep working, because Vercel serves files that exist before it applies rewrites.
The setting that silently breaks it
When we moved this site to Vercel, deep links still returned 404 with the rewrite in place. The cause was cleanUrls: true in the same file. Clean URLs changes how paths ending in .html are resolved, so the rewrite to /index.html never matched. Removing cleanUrls fixed every route at once.
Test it properly
Test with requests to inner URLs, not by clicking around the site, because in-app navigation never hits the server.
for path in / /services /blog/some-post /contact; do
echo "$path $(curl -s -o /dev/null -w '%{http_code}' https://www.example.com$path)"
doneEvery line should say 200. Then open one of those URLs in a fresh browser tab to confirm the right page renders.
One side effect to know about
With a catch-all rewrite, a URL that doesn't exist also returns 200 and shows your in-app 404 page. Search engines call this a soft 404. It's harmless for visitors, but the clean fix is to pre-render known routes and serve a real 404 for everything else. The last guide in this series covers that.