D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`opengraph-image` Returns 500 on Next.js 16: Unawaited `params`, Then Satori Rejects Two Text Nodes

· · 10 min read
`opengraph-image` Returns 500 on Next.js 16: Unawaited `params`, Then Satori Rejects Two Text Nodes

This bug turned up as a side note to other work. On 28 September 2026 I was researching a Next.js upgrade for a project with a page for every coin. The upgrade had been moved to the front because, according to a security advisory for Next.js, the installed 16.2.4 sat inside a critical vulnerability range fixed in 16.3.3. That read-only research recorded one finding the upgrade did not cause: the og:image of the coin pages answered 500 in production, and the bug was already there.

The commit message of the eventual fix put the consequence plainly: every opengraph-image address under the coin pages answered 500, so coin pages shared on social platforms had no image. Breakage like this is easy to miss, because opengraph-image is a separate route that platforms fetch when they build a link preview, not part of the page you open in a browser.

The research notes pointed straight at the cause: the coin pages' opengraph-image.tsx route did not await params. The finding was logged separately from the upgrade work, and I decided to fold the fix into the same upgrade package. The notes only named params, but behind that crash a second fault was waiting its turn.

params read like a plain object

Since Next 16, image routes such as opengraph-image receive params as a Promise. The coin card route in this repo was written as if params were a plain object: the first commit containing it, on 21 April 2026, already typed it as { slug: string } with no Promise around it. The repo came out of Create Next App a day earlier on next 16.2.4, and in the git history that version line only changed again with the upgrade to 16.3.6 on 28 September. Right before the fix commit, package.json still pinned 16.2.4. So the route was written with a plain object type on top of Next 16 from its first day.

Until the fix, the route read params.slug directly, without awaiting. A Promise object has no slug property, so the value was undefined, and the helper that turns a slug into a display name threw. That line arrived with the 31 August 2026 commit that removed fabricated prices. Since then the share card deliberately carries no price, and the same commit put a name derived from the slug and an address line on the card. Simplified, with the helper name and the domain changed, it looked like this:

// app/[slug]/opengraph-image.tsx, before the fix
// simplified; helper name and domain changed
import { ImageResponse } from "next/og";
 
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
 
export default async function Image({ params }: { params: { slug: string } }) {
  // on Next 16 params is a Promise: params.slug is undefined and this helper throws
  const name = toDisplayName(params.slug);
 
  return new ImageResponse(
    (
      <div style={{ display: "flex", flexDirection: "column" }}>
        <div style={{ fontSize: 72 }}>{name}</div>
        <div style={{ fontSize: 22, color: "#6a6a80" }}>
          example.com/{params.slug}
        </div>
      </div>
    ),
    { ...size },
  );
}

One small correction is worth recording. The draft code comment and commit message for the fix first said this change arrived in Next 15, and an independent review before the commit corrected both to Next 16. The difference matters if you are auditing another project. Next 15 already made params a Promise in pages, layouts and generateMetadata, but image routes such as opengraph-image only followed in Next 16. A project that was cleaned up properly for Next 15 can still hold image routes that read params directly.

The second fault waiting behind the first

Fixing params alone would not have been enough. The address line at the bottom of the card held the literal domain text and the slug as two text nodes, and the OG renderer rejects such a div unless it is display: flex. The project notes name that renderer: Satori, the engine behind next/og. The address div had only fontSize and color in its style, and no display at all.

What makes this easy to miss is how JSX works. Literal text followed by an expression, such as example.com/{slug}, produces two children, a string and a value. In an ordinary React page the browser renders them side by side without complaint, so the pattern looks safe. Satori is stricter: a div with more than one child node has to state its display explicitly.

The rule was not new to this file. On 11 May 2026, the client's infrastructure team passed along hosting logs that surfaced two pre-existing bugs, and the fix commit that day recorded this renderer error word for word:

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

That May commit also listed the coin card route among the affected routes, at the time on the logo wordmark div that lacked display: flex. On 31 August, almost four months later, the commit that removed fabricated prices introduced the same pattern in a different div, the address line, and this time the error sat behind the params crash. The throwing helper is above the return, so the JSX tree never reached Satori. Because the helper line and the address line arrived in the same commit, the address line never got a chance to render until the fix.

One template string, one text child

The fix was prepared as a single fix(og) commit with two changes: await params, and turn the address line into a single text child. In the diff, params is typed as a Promise and awaited before the slug is read, and the literal text plus slug is replaced by one template string. Simplified:

// after the fix (simplified)
// imports and the runtime, size, contentType exports are unchanged
export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const name = toDisplayName(slug);
 
  return new ImageResponse(
    (
      <div style={{ display: "flex", flexDirection: "column" }}>
        <div style={{ fontSize: 72 }}>{name}</div>
        {/* one text child, so this div needs no explicit display */}
        <div style={{ fontSize: 22, color: "#6a6a80" }}>
          {`example.com/${slug}`}
        </div>
      </div>
    ),
    { ...size },
  );
}

The Satori error itself offers another way out: give the div display: flex, contents or none. This fix merged the text instead, so the address div only has one child. Under a local next start, the coin og:image answered 200 png and the rendered card was checked visually before the commit. The fix then went in as the first of seven commits in the upgrade package, one commit before next was raised to 16.3.6.

The route that used to 500 became the release marker

To be sure staging was really running the new build, the release plan used two signals together: a build timestamp that moves, and the og:image of a coin page answering 200 image/png. The logic is simple. The old build answered 500 for every coin og:image, so a 200 on that route could only come from the new build.

On staging, the og:image for the home page and two coin pages answered 200 png, and the card was checked visually. The release merge was pushed at 12:31 UTC, and by 12:37 UTC at the latest, six minutes later, production had switched builds with the coin og:image answering 200 image/png. The production checks after a three minute wait recorded the og:image for the home page and two coin pages at 200 png and 1200x630, exactly the size the route declares.

Alive again, and willing to draw anything

The review before the commit had already left a small note: the coin OG route now drew a branded card for any slug, and a slug shape check was left as an optional item. On 29 September that item was skipped as low value. On 1 October a generic card for unknown slugs went into the security item about values that come from the URL, and on 5 October I decided the card should be built right away.

The 5 October commit message explains why this was more than tidiness. An og:image address for a coin page with any text in the slug position drew that text at 72 px under the site logo, complete with a matching address: a ready-made branded image for whoever wanted one. The fix makes the card name a coin only when the cache holds it. The card module asks Redis once, an EXISTS on the coin's snapshot and identity keys, over plain REST because the card runs on the edge runtime. A slug of the wrong shape never reaches Redis. That leaves four branches:

slug outside the id alphabet       plain card, Redis never asked
slug the cache holds               named card, as before
right shape, not held              plain card, nothing from the URL
Redis cannot answer                plain card, no-store, one log line

The plain card carries only a generic headline and the domain, and the route has a test making sure a slug the cache does not hold draws nothing from the URL. When Redis cannot answer, because it is not configured, the call is slow or fails, or the answer is not recognised, that plain card goes out with no-store so the CDN does not keep a guess for half an hour, and the server log gets a line. This path was seen working when tested with a deliberately wrong Redis token.

The shape rule is the id alphabet, not a word pattern, because at the time 18 of the 12,944 coins the cache held had ids with an underscore or a leading dash. The alphabet has no colon on purpose. Colons are the usual segment separator in Redis key names, and leaving that character out keeps the cached 404 marker keys out of reach of the card's lookup. Length is capped at 120 characters, while the longest id the cache held was 77. Since 6 October the card uses one shared id rule kept in a single file:

// shared id rule since 6 October (constant name changed)
const ID_SHAPE = /^[a-z0-9_-]{1,120}$/;

On a local production build on 5 October, a known coin's card was byte-identical to production, an id containing an underscore kept its name, and both an invented slug and a coin slug written with a capital letter got the same generic card. After the release as a separate commit, the production check recorded the known coin's card md5 unchanged and invented slugs getting the plain card. The og:image address of every coin page also picked up a new version suffix, because Next derives it from the route file.

Three questions for the next image route

If an opengraph-image route on Next 16 answers 500, the first question is whether its params is typed as a Promise and awaited. In this project the plain object type had been there since the route's first commit, and the 500 was found by research for other work.

The second question comes once the first crash is gone: is there a div holding more than one text node without an explicit display? Satori does not share the browser's leniency, and an error like this can hide behind another one, exactly as it did here.

The third question is the easiest to forget: once the card renders again, what does it draw for a slug that does not exist? A card that writes text from the URL under the site logo is a branded image anyone can order. Here the answer became a plain card for anything the cache does not hold, plus a test that keeps text from the URL off the image.