DEV Community

이준김
이준김

Posted on

Search Console found one 502. All 11 locales were broken, and no test could have caught it.

Search Console showed one URL in the "Server error (5xx)" bucket. One. On a site
with about 1,700 pages, that reads like noise, and I nearly left it.

The URL was /th/opengraph-image — the Thai locale's OpenGraph image. So I
checked the other ten locales before closing the tab.

en/opengraph-image: 502
ko/opengraph-image: 502
th/opengraph-image: 502
ja/opengraph-image: 502
de/opengraph-image: 502
Enter fullscreen mode Exit fullscreen mode

Every single one. The route had never worked, on any locale, and Search Console
knew about exactly one of them because it had only ever tried one.

Why nothing told me

This is the part worth internalising, because it generalises past OG images.

app/[locale]/opengraph-image.tsx is a Next.js file convention. You do not
import it, you do not link to it, and no page in your app renders it. Its entire
job is to make Next inject a <meta property="og:image"> tag pointing at a
route it generates for you.

So consider what could have caught this:

  • Not the browser. No page requests the image. You can click through the whole site and see nothing wrong.
  • Not the build. It compiles fine. The failure is at render time, per request.
  • Not the tests. Nobody writes a test for an image nobody imports.
  • Not the deploy checks. Mine fetch pages and grep for text. This route isn't a page.
  • Not a user report. The failure surface is a social preview card being blank on someone else's site.

The only witness was a crawler, and only because it stumbled across one locale.

The bug is a child count

next/og renders with Satori, which
implements a subset of CSS. One of its rules:

Expected <div> to have explicit "display: flex" or "display: none" if it has
more than one child node.

Now look at the line that broke it:

<div style={{ fontSize: 72, fontWeight: 800, maxWidth: 1000 }}>
  {TOOL_COUNT}+ Free Online Text Tools
</div>
Enter fullscreen mode Exit fullscreen mode

Read it as a human and it is one sentence. Read it as JSX and it is two
children
: the expression container {TOOL_COUNT}, and the text node
"+ Free Online Text Tools". The div has no display, so Satori throws.

The rest of the file was fine. Every other div either declared display: flex
or genuinely had one child. This one looked exactly like the others.

How a thrown renderer becomes a 502

Worth following, because the error you see is three layers away from the cause.

ImageResponse streams. By the time Satori throws, Next has already sent
response headers and started the body, so it cannot turn the failure into a 500
page — it closes the connection. Node logs:

Error: failed to pipe response
  [cause]: Error: Expected <div> to have explicit "display: flex" ...
Enter fullscreen mode Exit fullscreen mode

nginx, waiting upstream, logs:

upstream prematurely closed connection while reading response header from upstream
Enter fullscreen mode Exit fullscreen mode

and returns 502 to the client. So the outward symptom is a gateway error,
which is the vocabulary of infrastructure problems: you go and look at memory,
at the process, at the proxy config. The actual cause is a JSX child count.

The fix, and the one I did not choose

The error message asks for display: flex, and that works. I did something
smaller:

{`${TOOL_COUNT}+ Free Online Text Tools`}
Enter fullscreen mode Exit fullscreen mode

One template literal is one text node, so the multi-child rule never applies.
The reason to prefer it: adding display: flex to a text div also makes the
text a flex item, which changes how it wraps against that maxWidth: 1000.
The template literal fixes the crash and changes nothing about the layout.

Result across all eleven locales, in production:

200  image/png  244560 bytes   (1200x630)
Enter fullscreen mode Exit fullscreen mode

Three things I would do differently

1. Curl the route after touching it. It is the only check that exists.
Nothing in the normal loop covers a file that nothing imports.

2. Know which pages actually depend on it. This was the part that turned a
curiosity into a real bug. Pages that set openGraph.images explicitly override
the file convention and were fine. Pages that do not — my privacy policy and
terms pages, in all eleven locales — inherited it, and were publishing a 502 as
their og:image. I would not have guessed that split without checking:

curl -s https://example.com/en/privacy-policy \
  | grep -o '<meta property="og:image" content="[^"]*"'
Enter fullscreen mode Exit fullscreen mode

That gets you the tag. To see the card a crawler would actually build from
it, an Open Graph previewer
will fetch the page and render the result.

3. Treat a count of one as a sample, not a total. Search Console reports
what it happened to crawl. One 5xx URL on a multi-locale route means "at least
one", and the cheapest possible follow-up — a for-loop over the locale list —
turned 1 into 11.


This happened on textmachine.org, a set of text
tools that do their work in the browser.

One last detail I find genuinely funny. There is a second OG route in the same
codebase, app/og/route.tsx, which has always worked. I counted its divs: 15,
of which 13 declare display: flex. The two that do not are
<div>TEXT MACHINE</div> and <div>textmachine.org</div> — single text nodes,
where the rule does not apply. That file follows Satori's constraint exactly,
including knowing when it is not needed.

So the knowledge existed in the repository. It just did not exist in the second
file, because nothing carries a rule from one file to another except the person
writing it — and a rule you can only violate in a route nobody renders is a rule
you will violate eventually.

Top comments (0)