WatchNext

watchnext.leyu.studio · 2026-08-19 · by Long Nguyen ·generic
Google reported soft 404s across almost every show page on the site — months after we'd added proper metadata to all of them. The cause was a single property read, and the reason nobody caught it is that the failure is invisible from a browser.

WatchNext

← All posts

One line of JavaScript disabled server rendering on 190 pages

Google reported soft 404s across almost every show page on this site — months after we had deliberately added proper metadata to all of them. The cause was a single property read during render, and the reason it went unnoticed for so long is that this particular failure is invisible from a browser.

18 August 2026 · 6 min read · WatchNext

A while ago we did the SEO work properly. Every show page got a real title and description instead of the site-wide default, an Open Graph image, a sitemap that pulls in the shows people actually search for. It took a day. It worked — we 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 were not empty, not duplicated, not thin. Open any of them and you get a show, a poster, a trailer, a cast list, an episode list. They were, by any measure available in a browser, fine.

Looking at what was actually sent

The thing we 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 one is what a crawler sees first.

Stripping the tags out of the served HTML for a show page 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 we had verified — the content, the headings, the metadata that Google could in fact read — 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 that wraps the whole application, one line read the window width during render, to decide whether to use a short label or 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 is rendered 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 it in the browser instead. Which works. The user gets the page, slightly later, and nothing anywhere says that anything went wrong.

This is a reasonable thing for a framework to do. Degrading to a working page is better than showing a stack trace to a visitor. The cost is that the signal disappears along with the failure.

Why every kind of testing we did missed it

Look at the list of things that do not catch this, and the shape of the problem becomes clearer.

The build passes, because nothing is statically wrong. TypeScript passes, because `window.innerWidth` is a perfectly well-typed expression. The dev server is quiet. Clicking around the site works, because by then you are in the browser, where `window` exists. Lighthouse scores fine, because it runs JavaScript. Sharing a link produces a correct preview, because the metadata comes from a separate server function that never touched the broken code.

Every tool we 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 we had no feedback loop from, until Search Console told us months after the fact.

The fix, in two parts

The line itself is easy. Keep the value in state, start it at something that is true on the server, and set the real value after mount:

`const [isMobile, setIsMobile] = useState(false);`

…with a `useEffect` that reads `window.innerWidth`, sets the state, and subscribes to `resize`. The server renders the desktop label, the browser corrects it immediately if needed, and the first render matches on both sides so there is 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 that said “Loading show details…”. Technically server-rendered, still nothing to read.

So the page component became a server component that fetches the show and passes it to the client component as an initial value. The client component skips its own fetch when it already has one. The same function is used by `generateMetadata` and by the page body; Next deduplicates the call, so this costs one request, not two.

After both changes, the same measurement that returned one character returned about 1,750, with a real `<h1>`, the show overview, and the genre and description sections — present before any JavaScript runs.

What to take from it

Touching a browser API during render does not fail loudly. It fails by turning off the thing you cannot see from a browser. That is the whole lesson, and it generalises past this one property: `document`, `localStorage`, `navigator`, and anything that reaches them indirectly through a library.

The practical check takes ten seconds and we now do it after any change to a page that matters for search. Request the page the way a crawler would, strip the tags, and count what is left:

`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 is not server-rendered, regardless of what it looks like in a tab. Viewing source works just as well. The point is only that you have to look at the document the server sent, because that is the document the crawler judged.

The wider version of this is worth saying plainly. We wrote the metadata, verified it, and moved on — and the verification was done entirely inside the environment where the bug could not 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 difference between what the server produces and what the browser ends up with. The other one deleted people's saved shows, and also only appeared on a path developers rarely take.

WatchNext is a deliberately simple TV tracker: it tells you when the next episode of your favourite shows airs, for any show, in any country — and nothing else. Have a look.

watchnext.leyu.studio · 1 images · 12 links

Comments (0)

No comments yet