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
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>
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" ...
nginx, waiting upstream, logs:
upstream prematurely closed connection while reading response header from upstream
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`}
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)
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="[^"]*"'
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 (3)
This is a great example of why crawler-only routes need their own smoke tests. A normal page-visit test can stay green while metadata endpoints, OG images, feeds, or sitemaps are failing in production.
For a multilingual site, I’d add a small locale matrix to CI that requests every generated
/[locale]/opengraph-imageURL and asserts both a 200 response and an image content type. It could also verify that each page’sog:imagepoints to the expected locale route. That would have caught the issue without needing to test the visual output itself.The JSX child-count detail is especially easy to miss because it looks like one sentence to a human. Nice debugging write-up, and the template-literal fix keeps the intended text layout simpler than adding flex styling everywhere.
The three-layers-away symptom is the price of streaming, and it will come back.
ImageResponsecommits headers before Satori has finished, so the next unsupported thing in that file gets you another 502 from nginx rather than a stack trace at the boundary.We render PDFs from HTML and buffer the whole artifact before the first byte goes out. That costs the memory of one document, but a renderer that throws is still a 5xx with a body, and we can put facts about the render on response headers because nothing has been sent yet. For a 240 KB image that trade looks good.
Is there a way to make
ImageResponserender to a buffer first, or is the streaming baked in?Worth deciding where that locale matrix runs from, because the 502 is manufactured at nginx. A probe that hits the Node process directly gets a closed connection and no status line at all —
curl -w '%{http_code}'prints000in that case, and plenty of CI wrappers read that as a network blip and retry it until it looks green. So the assertion has to fail on "no answer" and not only on a wrong code, which is the same hole I ran into from the other side recently: an API endpoint that returns a clean 200 but simply does not carry the field I was asserting on.