Why your vibe coded PWA shows a grey install icon
I shipped a small vibe coded app a few weeks ago, opened it on my phone to install it to the home screen, and the install icon was grey. Not missing, not throwing an error. Grey, like the browser had gone looking for the app's identity and come back holding nothing.
If your vibe coded site or app suddenly cannot be installed and the icon shows up grey once you deploy, the fix is almost never in the manifest file you are about to go stare at. It is almost always that something sitting in front of your app is intercepting the request for that file before it ever arrives. The most common something is auth middleware: a route rule written to protect your pages that got scoped too widely and is now quietly eating a static asset fetch. The page itself loads fine, so nothing looks broken, but when the browser asks for your manifest it gets a redirect to a login screen instead of JSON, and it decides your app is not installable without telling anyone.
why is my install icon grey when the manifest looks fine?
Because the grey icon is a downstream symptom of an upstream interception, and the manifest is the last place the evidence points even though it is the first place you look.
A site becomes installable when the browser can fetch a valid web app manifest and the icons that manifest points at. If the browser requests your manifest and gets anything other than that JSON, a 401, a 302 to a login page, an HTML error, it silently marks the app not installable and shows a generic grey placeholder in place of your icon. No console error you would notice, no failed build, no red anywhere. The manifest file on disk is flawless. You can open it in your editor and read clean JSON, you can request it on your own machine and watch it come back perfect. None of that is the environment where it breaks. In production the request never reaches the file. It gets caught at the boundary, and boundaries do not throw. They just answer differently than you expected, and the browser believes the answer it was given.
the icon your middleware quietly ate
I know the exact shape of this because I lived it, and it cost me an hour I did not need to spend.
I had added auth middleware to gate the parts of the app that needed a signed-in user. The generated matcher was the broad kind, the version that protects nearly everything and lists a couple of obvious exceptions. It looked reasonable. It also matched the request for manifest.json, so every time a browser tried to read the app's identity, the middleware intercepted it and sent back a redirect toward the login route. On my laptop none of this fired, because in local development the auth layer was effectively out of the way, so the icon rendered and I shipped it believing it worked.
The hour went where everyone's hour goes. I opened the manifest and read it line by line. I checked the icon paths, the sizes, the purpose fields, the theme color, the start URL. I regenerated the icons. I cleared the cache and reinstalled. All of it was fixing a file that was never broken. What finally ended it was the network tab: the request for manifest.json was not returning my JSON at all, it was a 307 pointed at the login screen. The file was fine. The request was being answered by something else entirely, before it ever got there. The thing that looked broken and the thing that was broken were two different objects, and I had spent the whole hour on the wrong one.
what actually fixes a grey PWA install icon?
You scope the matcher, and you enumerate every request that is not a page as an explicit public allow, so the boundary stops treating your app's own assets like protected routes.
Your manifest is not the only non-page request that matters. The service worker, the icon files, the Open Graph image, the sitemap, the robots file, the favicons, none of these are pages, and none of them should ever be gated behind auth. A route rule that says "protect everything except these two paths" will happily swallow all of them the moment they are not on the exceptions list. The durable fix is to invert the default: name the exact set of public, non-page assets, allow them explicitly, and let the middleware guard only what is left. Then walk each one in production and confirm it returns its real content and not a redirect. When the manifest comes back as JSON on the deployed site, the grey placeholder turns back into your icon, because the browser can finally read who the app is.
how do I stop this at spec time instead of after a grey icon?
You write the allow-list before you generate the middleware, not after a symptom sends you hunting. That one reordering is the whole difference between a boundary you designed and a boundary you are reverse-engineering from failures.
This is spec-first discipline applied to a deploy boundary: the set of requests that must stay public is a contract you write down on purpose, in advance, so the implementation has to honor it, instead of you rediscovering each omission one silent failure at a time. It is the same move whether the boundary is a middleware matcher, a firewall rule, or a cache policy. Before the code exists, list every non-page fetch your app depends on to render, install, preview, and get indexed. That list is short, it is knowable, and it costs about five minutes to write. The generator will not write it for you, because vibe coding leans on generators that scaffold the happy path, the pages, and skip the seams between your app and the host where the quiet failures live. If you hand it the allow-list as part of the spec, the middleware gets built around a boundary you chose. If you do not, the boundary gets chosen by whatever the matcher happened to catch, and you meet it later as a grey icon, a missing preview card, or a page that never showed up in search.
what tends to break with this
The first failure mode is the one I lived: debugging the artifact that looks broken instead of the boundary in front of it. The manifest is right there and it is easy to read, so it absorbs the attention, while the middleware that actually answered the request is invisible unless you go look at what came back over the wire. Check the response before you edit the file. If the thing on disk is correct, the problem is between the browser and the disk.
The second is that it only shows up in production, which is what makes it feel like a deploy jinx rather than a bug. Locally there is usually no auth layer, or dev mode bypasses it, so the request sails through and the icon renders. The environment where you judged it working is the one environment guaranteed not to have the thing that breaks it. This is a close cousin of the way a vibe coded build works in preview and breaks on deploy, and it has the same root: the boundary between your app and the host only exists in production, so it can only be tested there.
The third is scope creep in the matcher over time. Even if you get it right once, every new protected feature is a chance for someone, or some agent, to widen the rule and quietly re-capture an asset that used to be public. The allow-list is not a one-time fix, it is a line in the spec that every future change has to respect. Without it written down, the next broad matcher eats the icon again and the hour repeats.
questions that keep coming up
It works locally but not once I deploy. Why only in production? Because the middleware and auth that intercept the request usually are not active on your machine. The manifest request only meets the boundary after deploy, so that is the only place the interception happens. Open the deployed site's network tab and look at what the manifest request actually returns before you touch anything.
How do I even see this failure? The network tab, filtered to the manifest request. A grey icon plus a manifest request that returns a 401, a 301, a 302, or an HTML login page instead of JSON is the whole diagnosis. If the status is not 200 and the type is not JSON, the file is not your problem.
Is this only a PWA thing? No. The same interception hits your sitemap, your robots file, and your Open Graph preview image, because they are all non-page requests that a broad matcher treats like pages. A grey install icon is just the most visible member of that set. If one non-page asset is being gated, check the others in the same pass.
If you are a vibe coder shipping a site that has to install, preview, and get indexed cleanly for real visitors, and you want a second set of eyes on the boundary where your middleware meets your static assets, /work-with-us. Website work starts by walking every non-page request the app depends on, in production, not just the pages you can already see.
The manifest was never broken. The request just never got the chance to say so.
// part of the ai websites topic
// grab the free starter kit that makes your AI stop forgetting and stop guessing: get it →
// building with AI? the field manual has the structured lessons.
// hitting this on a real build? this is what I fix →