Why your site works locally and 404s in production
Three unrelated bugs produce the same production 404: missing SPA fallback, case-sensitive filesystems, and static exports that only serve what the build enumerated. Here's how to tell them apart in ninety seconds and a script that catches the worst one before deploy.
I'll research the current state of these deployment gotchas before writing.Your app runs fine on localhost:5173. You push, the build goes green, and /dashboard/settings returns a 404 the moment someone hard-refreshes it.
This is the single most common "but it worked on my machine" bug in the club's help channels, and it's almost never one bug. It's three different bugs that all produce the same 404, and they need three different fixes. The reason it's confusing is that your dev server is a helpful, forgiving piece of software running on your laptop's forgiving filesystem, and production is neither of those things.
Let's work one app all the way through. Vite + React + React Router, deployed as static files. It has a /dashboard/settings route and a UserCard component. In dev it's perfect. In production it 404s twice, for two unrelated reasons, and the second one only shows up after you fix the first.
Three questions that narrow it down in about ninety seconds
Before you touch any config, answer these:
Does it 404 on hard refresh but work when you click through from the homepage? That's server-side routing. The client router works fine; the server has never heard of the URL.
Does it 404 immediately on every path including /, or do you get a blank page with red asset errors in the console? That's a base path or publish directory problem.
Does it 404 only for some routes, or only for one API endpoint, while everything else works? That's case sensitivity or a missing pre-rendered path.
Open DevTools, Network tab, hard refresh, and look at what actually 404s. If the document request itself is a 404, it's routing. If the document is 200 but /assets/index-a1b2c3.js is a 404, it's paths. Those are completely different conversations and people waste hours conflating them.
Failure one: nobody told the server that /dashboard/settings exists
Here's what your build actually produces:
dist/
index.html
assets/index-a1b2c3.js
assets/index-d4e5f6.css
One HTML file. That's it. There is no dist/dashboard/settings.html, because React Router builds those views at runtime in the browser.
When a user hard-refreshes /dashboard/settings, the browser doesn't ask your router anything. It sends GET /dashboard/settings to a static file server, which looks on disk for something at that path, finds nothing, and returns a 404. This mechanic is described plainly in DigitalOcean's community answer on the topic: the server tries to find a resource that doesn't exist, because routing is handled client-side.
Your dev server doesn't do this. It's designed to hand back index.html for anything it doesn't recognise, so the client router always gets a chance to run. Production has no such courtesy.
The fix is to tell the host: if you can't find a real file, serve index.html anyway.
Netlify, in a _redirects file:
/* /index.html 200
The 200 matters. A 301 or 302 changes the URL in the address bar; 200 serves index.html while keeping the requested path intact, which is exactly what the router needs to read.
The gotcha specific to Vite: index.html lives at the project root, not in public/, so people put _redirects in the wrong place and conclude the fix doesn't work. There's a Netlify forum thread that is basically one person trying every possible location. Put it in public/ — Vite copies that directory verbatim into dist/, which is what gets published.
Nginx:
location / {
try_files $uri $uri/ /index.html;
}
Vercel, in vercel.json:
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
Same idea everywhere. Cloudflare Pages and the other hosts have their own equivalent — check their docs rather than trusting my memory of the syntax, because I haven't verified it and getting it subtly wrong costs you another deploy cycle.
If you're on Lovable or a similar tool and the preview works but the hosted version 404s on refresh, this is the same bug — the preview environment is doing the fallback for you and the real host isn't.
The tradeoff you just accepted
That catch-all rewrite has a real cost, and almost nobody mentions it.
Your server will now never return a 404. Ever. /dashboard/settings returns index.html, and so does /dashbord/setings, and so does /wp-admin.php. As frontendundefined puts it, nginx will always serve index.html regardless of the path requested.
Consequences you'll feel:
Search engines index your typo URLs as real pages returning 200. Your uptime monitor can't distinguish "site is healthy" from "site is serving a blank error shell", because both are 200. And your error tracking loses a useful signal — a spike in server 404s used to tell you a deploy dropped a route.
The mitigation is a catch-all route in the client router that renders a real "not found" view:
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard/settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Routes>
The user sees a proper 404 page. The HTTP status is still a lie. You can't fix that with a static SPA, full stop — fixing it properly means server-rendering or pre-rendering each route so real files exist on disk. That's a bigger architectural call. For most side projects, accept the lie and move on. Just make the decision knowingly instead of discovering it six months later when someone asks why Google indexed 400 phantom pages.
Failure two: your laptop thinks Avatar.tsx and avatar.tsx are the same file
You've deployed the rewrite. Refresh works. Now the build fails, or worse, one component silently doesn't render in production.
macOS is case-insensitive by default. Linux, which is what your build container and your production server are running, is not. So this:
// src/components/UserCard.tsx
import Avatar from "./avatar";
resolves happily on your machine against a file named Avatar.tsx, and dies on the build server with Module not found: Can't resolve './avatar'. There's a decent write-up of exactly this scenario — files that visibly exist locally, module-not-found on Vercel.
This is much more common now than it used to be, and I think AI coding tools are a big part of why. Claude Code creates Avatar.tsx. Twenty minutes later, in a different context window, it writes from "./avatar" because that's the more common convention in its training data. Both are reasonable. Together they're a production-only failure. If your components directory has a mix of PascalCase.tsx and kebab-case.tsx files, you are already carrying this bug somewhere.
It bites routes too, not just imports. On Next.js, an API route at pages/api/someTestApi/index.js worked in dev and 404'd in the production build — renaming it to lowercase made both environments agree. And URL matching itself is case-sensitive in ways that aren't uniform: /about renders the page while /ABOUT 404s, but redirects match case-insensitively. So a redirect can happily send you to a URL that then 404s.
Git is in on the conspiracy
Here's the part that turns a five-minute fix into an afternoon.
You spot the mismatch, rename avatar.tsx to Avatar.tsx in your editor, commit, push. The build still fails. You check GitHub and the file is still lowercase.
Git detects that it's on a case-insensitive filesystem and sets core.ignoreCase for the repo, and with that on, Git doesn't consider a file renamed if only its case has changed. Your rename is invisible to git status. You are shipping the same broken file over and over while staring at a correctly-named file on disk.
Two ways out. Adam Johnson's post walks through git rm --cached and re-adding, which stages the removal without deleting anything on disk and lets Git see the rename. The blunter version is a two-step move through a temporary name:
git mv src/components/avatar.tsx src/components/avatar.tmp.tsx
git mv src/components/avatar.tmp.tsx src/components/Avatar.tsx
git status # now it shows the rename
Verify with git ls-files src/components rather than ls. ls shows you your filesystem's opinion; git ls-files shows what will actually reach the build server. Only one of those matters.
Catch it before the deploy does
Here's a script that checks every relative import against the real on-disk casing. Drop it in your repo root as check-case.mjs. No dependencies, runs in about a second on a mid-size project.
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { dirname, resolve, basename, join } from "node:path";
const EXTS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".css",
"/index.ts", "/index.tsx", "/index.js"];
const walk = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = join(dir, e.name);
if (e.isDirectory()) return e.name === "node_modules" ? [] : walk(p);
return /\.(ts|tsx|js|jsx|mjs)$/.test(e.name) ? [p] : [];
});
// exists AND every segment matches the on-disk casing exactly
const existsExact = (p) =>
existsSync(p) && readdirSync(dirname(p)).includes(basename(p));
// what the author probably meant, ignoring case
const findLoose = (p) => {
try {
const want = basename(p).toLowerCase();
const hit = readdirSync(dirname(p)).find((f) => f.toLowerCase() === want);
return hit ? join(dirname(p), hit) : null;
} catch { return null; }
};
let bad = 0;
for (const file of walk("src")) {
const src = readFileSync(file, "utf8");
for (const [, spec] of src.matchAll(/from\s+["'](\.[^"']+)["']/g)) {
const base = resolve(dirname(file), spec);
if (EXTS.some((e) => existsExact(base + e))) continue;
const loose = EXTS.map((e) => findLoose(base + e)).find(Boolean);
console.log(`${file}\n import "${spec}"\n ${loose ? "case mismatch, on disk: " + loose : "not found at all"}`);
bad++;
}
}
console.log(bad
? `\n${bad} import(s) will break on a case-sensitive filesystem.`
: "All relative imports match disk casing.");
process.exit(bad ? 1 : 0);
The trick is existsExact. existsSync alone is useless on macOS because it returns true for the wrong casing — that's the whole problem. Reading the parent directory and doing an exact string match against the entries bypasses the filesystem's helpfulness.
Run it on the deliberately-broken fixture I used while writing this and it prints:
src/components/userCard.tsx
import "./avatar"
case mismatch, on disk: /tmp/demo/src/components/Avatar.tsx
src/components/userCard.tsx
import "../lib/Format"
case mismatch, on disk: /tmp/demo/src/lib/format.ts
2 import(s) will break on a case-sensitive filesystem.
Wire it into package.json so it runs before every build:
{
"scripts": {
"check:case": "node check-case.mjs",
"prebuild": "npm run check:case"
}
}
Limitations, honestly: it only handles static relative from "..." specifiers. It won't catch require(), dynamic import() with template literals, or path aliases like @/components/Avatar. Extend the regex if you need those. It catches the overwhelming majority of what AI-generated code gets wrong, which is the point.
Next.js and static exports have their own version
If you're on the App Router with output: 'export', the build has to emit a file for every URL the site will ever serve, and dynamic routes get their list from generateStaticParams.
The trap is dynamicParams. Per the Next.js docs, setting export const dynamicParams = false means any path not returned by generateStaticParams becomes a 404. So if your function fetches posts from a CMS at build time and returns the first ten, post eleven 404s in production and works perfectly in dev, where the route renders on demand.
Same class of bug, different mechanism: dev renders on request, production only serves what the build enumerated. If a route 404s in prod and you're on a static export, go read what your generateStaticParams actually returned. Log it during the build. Don't assume.
And if every path 404s including /, or the document loads but assets don't, check base in vite.config.js against where the site is actually served from. On GitHub Pages under a repo subpath, base has to match the deployment path or the asset URLs point at the domain root and nothing loads.
"Just develop in a Linux container"
Fair objection, and it works. A dev container on ext4 makes case mismatches fail on your machine, immediately, in the tightest possible feedback loop. If you're already using Docker for the app, do it.
But it doesn't cover the SPA fallback problem, because your dev server is still doing the friendly catch-all regardless of the filesystem underneath it. And it costs you file-watching performance on macOS through the virtualisation layer, which is a real daily tax to pay for a bug that hits monthly.
The other suggestion you'll see is setting core.ignoreCase false. That makes Git notice case renames, which helps with the second half of the problem. It doesn't stop your editor and your bundler from resolving the wrong file in the first place, and the setting gets restored on a fresh clone anyway — the Embedded Artistry write-up on case-sensitive rename divergence notes it's applied automatically on case-insensitive filesystems. Useful, not sufficient.
The cheap combination: keep developing on macOS, run the case check in prebuild, and configure the SPA fallback once per project. Two files, ten minutes, done forever.
Do this today
Pick your most recently deployed project. Add check-case.mjs, wire up the prebuild hook, and run npm run build. If it flags anything, fix the imports with the two-step git mv and confirm with git ls-files, not ls.
Then open the live site, navigate to your deepest route, and hard-refresh it. If you get a 404, add the rewrite for your host and a path="*" route in your router. Ten minutes for both.
If it turns up something weird — an alias that resolves in dev and not in the build, a host whose rewrite rules do something unexpected — bring it to the Discord. Someone's usually hit the same wall that week: discord.gg/3scUHe7B.

Timothy Yang
Founder & CEO, DrillCall
Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.
Build with us
Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.
JOIN THE DISCORD →