Someone sent me a screenshot of my own 404 page. Washed-out grey text on a light background, barely readable, nothing like the dark theme every other page uses. My first thought was a styling bug. It was not a styling bug. The page was crashing before it could paint, and the fallback I had built for exactly that case was doing its job. Underneath it was a second bug that had been hiding the first one for months. Bug one: the status code was a lie The site is a Flutter web app with every route prerendered to a real HTML file. Firebase Hosting served those files, and anything unmatched hit a catch-all rewrite: "rewrites": [ { "source": "**", "destination": "/404.html" } ] That looks right. It is not, and the reason is worth internalising: a Firebase rewrite always responds 200. That is what a rewrite is — serve this other content under the requested URL. So every nonexistent URL on the site returned 200 OK with a page that said "Page not found". Browsers do not care. Crawlers care a great deal. A 200 means "this is a real page, index it", so every typo, every dead inbound link, every scanner probing for /wp-admin was eligible to be indexed as a real page. This is the soft 404, and it is invisible from a browser because the page looks correct. The fix is to delete the catch-all rather than repoint it. With every real route prerendered as a file, Firebase serves those directly, and for anything with no matching file it falls through to its own handling — which serves 404.html with an actual 404 status. One caveat that will bite you if your app has client-only routes. My admin panel has no prerendered file, so removing the catch-all 404'd my own admin URL. It needs a rewrite scoped to that path alone: "rewrites": [ { "source": "/your-client-only-path/**", "destination": "/index.html" } ] Scoped rewrites for the routes that genuinely need them; no catch-all; real 404s for everything else. Bug two: the page was crashing the whole time Fixing the status code is what made me actually look at the page, and that is when the grey rendering stopped looking like a theme problem. My prerendered HTML carries the page's text in the DOM, clipped to a single pixel, with a boot guard that reveals it if Flutter never paints. The idea is that a failed boot shows the content rather than a spinner turning forever. So the washed-out text was the guard firing correctly, telling me Flutter had died. The console had it: GoError: There is no GoRouterState above the current context. This method should only be called under the sub tree of a RouteBase.builder. Here is the chain. errorBuilder renders my NotFoundPage. That page uses the same shell as every other page, and the shell contains the nav bar, and the nav bar asks which route is current so it can highlight the right link: final current = GoRouterState.of(context).uri.path; On every real route that is fine. On the 404 page it throws, because errorBuilder renders outside any RouteBase.builder — there is no route subtree above it and therefore no GoRouterState to inherit. The fix that does not exist, and the one that also fails The instinct is maybeOf. In go_router 17 there is no GoRouterState.maybeOf. of() throws unconditionally; there is no nullable variant to fall back to. So the next instinct is to ask the router itself, which definitely sits above everything: current = GoRouter.of(context).state.uri.path; // also throws I tried exactly this, and it fails too — differently, which is what makes it interesting. GoRouter.state reads matches.last on the current match list, and on an unmatched URL that list is empty, so you get a StateError: Bad state: No element. The accessor fails for precisely the reason you are on the 404 page at all. Both router-side answers are dead ends. But the browser knows the path regardless of what the router made of it: String current; try { current = GoRouterState.of(context).uri.path; } catch (_) { current = Uri.base.path; } The broad catch is deliberate, and I would defend it specifically here. Determining which nav item to highlight is decoration. This widget renders inside every page on the site. Failing at decoration must never be able to take down the page around it, and the two failure modes above are different exception types from different call sites — narrowing the catch buys nothing except a chance to miss the third one. Nothing highlights on a 404, which is correct: no nav item corresponds to a page that does not exist. Why it survived so long Because the two bugs concealed each other. The 404 page had been broken for months and rendered identically whether it answered 200 or 404 — so nothing looked wrong from a browser, and nothing in the logs distinguished it. Crawlers saw 200 and were happy. I never visited my own 404 page, because why would I. The status-code fix did not cause the crash. It made someone look at the page, which is a different and more useful thing. Two habits fall out of this. Check your 404 page's status code, not just its appearance — curl -o /dev/null -w '%{http_code}' https://yoursite/nonsense takes two seconds and is the only way to see a soft 404. And actually load your error page in a browser after a routing change, because it is the one page in your app that renders through a completely different code path from every other page, and therefore the one page your testing never touches. Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.