A while ago I did the SEO work properly. Every show page on my TV tracker got a real title and description instead of the site-wide default, an Open Graph image, a sitemap pulling in the shows people actually search for. It took a day. It worked — I checked the pages, the tags were there, the previews rendered.
Months later Google Search Console reported that roughly 190 of those pages were soft 404s: URLs that return a success status but look, to a crawler, like an error page.
The obvious readings were all wrong. The pages returned 200. They weren't empty, duplicated, or thin. Open any of them and you get a show, a poster, a trailer, a cast list, an episode list. By any measure available in a browser, they were fine.
Looking at what was actually sent
The thing I had never done was look at the HTML the server sent, as opposed to the page the browser ended up showing. Those are different documents, and only the first is what a crawler sees first.
curl -s https://watchnext.leyu.studio/info/125988 | sed 's/<[^>]*>//g'
That left one character of body text.
Not a truncated page. Not a slow page. The server was sending an empty shell and the entire visible site was being assembled in the browser afterwards. Everything I had verified existed only after JavaScript ran. To a crawler taking a first look there was nothing on the page at all — which is exactly what a soft 404 means.
The cause
Somewhere in a context provider wrapping the whole app, one line read the window width during render, to pick a short label over a long one:
const isMobile = window.innerWidth < 640;
On the server there is no window. That line throws ReferenceError: window is not defined every single time the page renders on the server.
Here is the part that turns a small mistake into a large one. Next.js does not fail the build, and it does not show an error. It catches the exception, gives up on server rendering that page, and falls back to rendering in the browser. Which works. The user gets the page, slightly later, and nothing anywhere says anything went wrong.
That's a reasonable thing for a framework to do — degrading to a working page beats showing a visitor a stack trace. The cost is that the signal disappears along with the failure.
Why every kind of testing I did missed it
Look at what doesn't catch this:
- The build passes — nothing is statically wrong.
-
TypeScript passes —
window.innerWidthis a perfectly well-typed expression. - The dev server is quiet.
-
Clicking around works — by then you're in the browser, where
windowexists. - Lighthouse scores fine — it runs JavaScript.
- Sharing a link gives a correct preview — metadata comes from a separate server function that never touched the broken code.
Every tool I had was either running JavaScript or checking something orthogonal. The one observer that behaves differently — a crawler forming a first impression from raw HTML — was the one I had no feedback loop from, until Search Console told me months after the fact.
The fix, in two parts
The line itself is easy. Keep the value in state, start at something true on the server, set the real value after mount:
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const sync = () => setIsMobile(window.innerWidth < 640);
sync();
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, []);
The server renders the desktop label, the browser corrects it immediately if needed, and the first render matches on both sides so there's no hydration mismatch either.
The second part was less obvious. With server rendering restored, the show pages served their layout — but the show data was still fetched in the browser, so the server was sending a page reading "Loading show details…". Technically server-rendered. Still nothing to read.
So the page component became a server component that fetches the show and hands it to the client component as an initial value:
// page.tsx — server component
export default async function Page({ params }) {
const show = await getShow(params.id);
return <ShowDetailsClient initialShow={show} />;
}
// ShowDetailsClient.tsx — "use client"
const [show, setShow] = useState(initialShow);
const [isLoading, setIsLoading] = useState(!initialShow);
useEffect(() => {
if (initialShow) return; // already have it, don't refetch
// ...client fetch for the navigation case
}, [initialShow]);
The same getShow() is used by generateMetadata and by the page body. Next deduplicates the call, so this costs one request, not two.
After both changes, the measurement that returned one character returned about 1,750, with a real <h1>, the overview, and the genre and description sections — all present before any JavaScript runs.
What to take from it
Touching a browser API during render doesn't fail loudly. It fails by turning off the thing you can't see from a browser. That's the whole lesson, and it generalises past this one property: document, localStorage, navigator, and anything reaching them indirectly through a library.
The practical check takes ten seconds, and I now do it after any change to a page that matters for search:
curl -s https://example.com/some-page | sed 's/<[^>]*>//g'
If the result is a spinner, a loading message, or nothing at all, your page isn't server-rendered regardless of what it looks like in a tab. View-source works just as well. The point is only that you have to look at the document the server sent, because that's the document the crawler judged.
The wider version is worth saying plainly. I wrote the metadata, verified it, and moved on — and the verification happened entirely inside the environment where the bug couldn't appear. The work was real and the checking was real, and neither was worth much, because both happened on the wrong side of the line.
This is the second bug on this site whose root cause was the gap between what the server produces and what the browser ends up with. The other one deleted people's saved shows, and also only showed up on a path developers rarely take.
Top comments (0)