My Images Load Locally and 404 in Production

Written By
SprintX Team
AI & Product Engineering
August 08, 2026
6 min read

Broken images after a deploy are rarely about the images. They are about filesystem case, asset pipelines, and storage rules — and each has a specific fix.
Your app looks right on your laptop. Every logo, avatar, and product photo renders. You deploy, open the live URL, and half the page is broken-image icons and alt text. The code did not change. The images are still in the repo. And yet.
Broken images in production are almost never about the images. They are about the four systems between the image and the browser: your filesystem, your bundler, your host, and — if the file is user-uploaded — your storage permissions. Each of those systems is more forgiving on your machine than it is on a server, and the difference shows up as a 404.
The diagnosis takes about two minutes if you start in the right place.
Start with the failing request, not the code
Open DevTools, go to the Network tab, filter to Img, and reload. Look at the exact URL being requested and the status code. That one line usually names the cause:
- 404 with a path you recognize — the file is not where the server expects it. Case, folder, or bundling.
- 404 with a hashed or transformed path — the image optimizer failed, not the file.
- 403 — the file exists but you are not allowed to read it. Storage permissions.
- 200 but no image renders — wrong content type, a zero-byte file, or a placeholder returned by a catch-all route.
- Blocked or CORS error — a cross-origin or mixed-content problem, not a missing file.
Copy the failing URL into a new tab. If it loads there and not in the app, the problem is referrer policy or CORS. If it 404s there too, keep reading.
Cause 1: case sensitivity, the one that gets everyone
macOS and Windows treat Logo.png and logo.png as the same file. Linux, which is what your production server runs, does not. So an import that reads './assets/Hero.PNG' while the file on disk is 'hero.png' works flawlessly on your machine and 404s everywhere else.
This is by far the most common cause of "images worked locally", and it applies to folder names too — Components/Icons versus components/icons. AI-generated code produces these constantly because the model writes an import path from memory rather than from the filesystem.
The fix is mechanical: normalize every asset filename and every reference to lowercase, and verify with git that the rename registered. Git on a case-insensitive filesystem will happily ignore a rename from Logo.png to logo.png unless you force it, so check that the committed path actually changed rather than assuming it did. The same trap breaks component imports and is worth understanding generally, which the guide on apps that work locally but not in production covers alongside its cousins.
Cause 2: the file lives somewhere the build does not copy
Frontend tooling has strong opinions about where static files go, and they are not intuitive.
Files in the public folder are copied verbatim to the output root and referenced by absolute URL from the site root. Files imported in code — 'import hero from "./hero.png"' — get hashed, fingerprinted, and rewritten by the bundler, and you must use the imported variable rather than a hardcoded string.
The failure mode is mixing the two. Code references '/src/assets/hero.png' as a literal string, which resolves during development because the dev server serves your source tree, and then 404s in production because no build output contains a src folder. If the URL in your Network tab contains 'src' or 'assets' without a content hash, this is your bug.
The rule: if you want to reference an image by a stable path, put it in public and use a root-relative URL. If you want the bundler to optimize and fingerprint it, import it and use the returned value. Never hardcode a path into a bundled asset.
Cause 3: image optimization that cannot fetch the source
Frameworks that optimize images at request time need permission to fetch the original. Next.js requires remote hostnames to be listed explicitly in its image configuration; an unlisted domain produces a 400 or 500 from the optimizer rather than a plain 404, and the image never renders even though the source URL is perfectly valid.
Two related traps. Static exports cannot run a server-side optimizer at all, so an app deployed as static HTML must either disable optimization or pre-optimize at build time. And optimizers on some hosts have a per-plan quota — cross it and images start failing in a way that looks intermittent and random. If your images broke gradually rather than all at once, check the quota before you check the code. The host-specific settings are covered in deploying an app to Vercel.
Cause 4: storage permissions on user-uploaded files
If the images are avatars, listings, or anything a user uploaded, the file lives in object storage and the rules are different. A 403 means the bucket is private and your app is requesting the file without a signed URL, or the access policy allows the uploader to read the file and nobody else.
This is the mirror image of the classic security bug. Making the bucket fully public fixes the broken image and simultaneously exposes every user's uploads to anyone who can guess a filename — which, with sequential IDs, is trivial. The correct fix is a read policy scoped to who should see the file, plus signed URLs with short expiry for anything private. If your storage policies were generated rather than written, Supabase RLS mistakes in AI-generated apps covers the same class of error on the database side.
Also check that you are storing a path and constructing the URL at read time, rather than storing a full URL that includes a signed token. Stored signed URLs expire, and your images will work perfectly for exactly one hour after each upload.
Cause 5: environment and domain assumptions
Two smaller ones that account for a real share of cases. If your image base URL comes from an environment variable that was never set on the host, the browser requests 'undefined/photo.jpg' — a distinctive URL that immediately names the cause. That family of problems is covered in environment variables that are not working.
And if your production site is HTTPS while an image URL is HTTP, browsers block it as mixed content with no visible request at all. The console says so explicitly.
Quick reference
| Network tab shows | Cause | Fix |
|---|---|---|
| 404, path matches your file but different case | Linux case sensitivity | Lowercase files and references |
| 404, path contains /src/ | Bundled asset referenced as a string | Import the asset or move to public |
| 400/500 from an optimizer route | Remote domain not allow-listed | Add the hostname to image config |
| 403 | Private bucket or missing read policy | Signed URLs or a scoped read policy |
| URL starts with 'undefined' | Missing environment variable | Set it on the host, rebuild |
| Blocked: mixed content | HTTP asset on an HTTPS page | Serve assets over HTTPS |
Stop it happening again
Three habits remove this category permanently. Enforce lowercase, hyphenated asset filenames as a convention and add a lint rule or a pre-commit check for it. Run a production build locally and serve the output before every deploy, so case and path errors surface on your machine. And add a broken-image check to your post-deploy smoke test — a headless page load that reports any image request returning a non-200 takes an hour to write and catches this class of bug forever.
Frequently asked questions
Why do some images work and others do not? Almost always case sensitivity or a mix of reference styles. The images that work are the ones whose filenames happen to match exactly, or that live in public while the broken ones are imported from a source folder. Compare a working URL against a failing one in the Network tab — the difference will be structural, not random.
Why do my images load, then disappear after an hour? You are storing signed URLs in the database instead of storage paths. Signed URLs expire by design. Store the object path and generate a fresh signed URL when the client requests it.
Should I just make the storage bucket public? Only if every file in it is genuinely public content, like marketing images. For anything a user uploaded, public means enumerable by anyone with the URL pattern, which is a data exposure incident waiting for someone to notice. Use scoped read policies instead.
If a deploy turned your product pages into broken-image icons and the URLs all look correct to you, the mismatch is usually somewhere in the pipeline rather than the markup. SprintX untangles asset, storage, and deployment configuration in AI-generated apps and leaves you with a build that behaves identically on every machine. Send us your repo or a link to the broken page.


