← 記事一覧に戻る

Next.js + Vercel EdgeでOG画像が真っ白になる3つの罠

Next.jsのImageResponse(satori)を使って動的なOG画像を生成したところ、ローカルでは動くのに本番で真っ白な画像が返る、500エラーになる、サイズ0バイトのレスポンスが返る、という地獄にハマった。

原因は3つの罠の複合。1つずつ解説する。


罠1: satoriは全divに display: "flex" が必須(サイレントクラッシュ)

症状

  • OG画像APIが200を返すがレスポンスボディが空(0バイト)
  • または500 Internal Server Error
  • エラーメッセージが一切出ない(Edge Runtimeではconsole.errorも見えない)

原因

satoriのレンダリングエンジンは、子要素が複数あるdivには必ずdisplay: "flex"が必要。省略するとプロセスがクラッシュする。

// NG: クラッシュする
<div style={{ fontSize: "24px", color: "#333" }}>
  <span>Hello</span>
  <span>World</span>
</div>

// OK: display: "flex" を明示
<div style={{ display: "flex", fontSize: "24px", color: "#333" }}>
  <span>Hello</span>
  <span>World</span>
</div>

発見方法

ローカルのdevサーバー(next dev)でリクエストすると、ターミナルにエラーが出る:

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

本番のEdge Runtimeではこのエラーが表示されないのが厄介。

対策

  • 全てのdivにdisplay: "flex"を付ける(脳死で付けて問題ない)
  • 開発時は必ずローカルのdevサーバーでテストし、ターミナルのエラーを確認する
  • try-catchでImageResponseを囲み、エラー時にJSONを返すようにしておく
try {
  return new ImageResponse(<div>...</div>, { width: 1200, height: 630 });
} catch (e) {
  return new Response(
    JSON.stringify({
      error: String(e),
      stack: e instanceof Error ? e.stack : undefined,
    }),
    { status: 500, headers: { "content-type": "application/json" } }
  );
}

罠2: Next.jsのmetadataで &&amp; にエスケープされる

症状

  • OG画像API自体はブラウザで直接開くと正常に画像を返す
  • しかしSNSクローラーが画像を取得できず、OGPチェッカーで「OG Image URL appears to be invalid」と表示される

原因

Next.jsのgenerateMetadataでOG画像URLを返すと、HTMLレンダリング時に&&amp;にエスケープされる。

// generateMetadataで返す
images: [{
  url: "https://example.com/api/og?company=test&salary=300000&hourly=2000"
}]

// 実際のHTML出力
<meta property="og:image"
  content="https://example.com/api/og?company=test&amp;salary=300000&amp;hourly=2000" />

SNSクローラーがこのURLをそのままGETすると、パラメータが壊れて画像生成に失敗する。

対策: クエリパラメータを1つにまとめる

全パラメータをJSONにしてBase64urlエンコードし、単一のdパラメータで渡す。

// シェアページ(サーバーコンポーネント)
const ogData = Buffer.from(
  JSON.stringify({ company, salary, hourly, minashi })
).toString("base64url");

const ogImageUrl = `${SITE_URL}/api/og/newgrad?d=${ogData}`;
// → ?d=eyJjb21wYW5... (&が不要)
// OG画像API(Edge Runtime)
const d = searchParams.get("d");
if (d) {
  const b64 = d.replace(/-/g, "+").replace(/_/g, "/");
  const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
  const binary = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
  const jsonStr = new TextDecoder().decode(binary);
  const decoded = JSON.parse(jsonStr);
}

注意点

  • btoa()はマルチバイト文字(日本語)を扱えない。サーバー側はBuffer.from().toString('base64url')、クライアント側はbtoa(unescape(encodeURIComponent(str)))を使い分ける
  • Edge Runtimeのatob()はBase64url形式を直接デコードできないので、-+_/の変換とパディング追加が必要
  • デコード後のバイト列はUTF-8なのでTextDecoderを通す必要がある(atobはLatin-1を返す)

罠3: Edge Runtimeでの日本語フォント読み込み

症状

  • OG画像APIがフォント読み込みで500エラー
  • またはフォントは読めるが5MBのTTFでメモリオーバーフロー

原因

satoriはデフォルトでASCIIフォントのみ搭載。日本語テキストを含むOG画像には外部フォントが必要…と思いきや、実はフォントなしでも日本語は描画できる

ただし以下の落とし穴がある:

  1. Google Fonts CSSのレスポンスはUser-Agentで変わる

    • ブラウザ: woff2形式を返す
    • Edge Runtime / curl: ttf形式を返す
    • CSSからwoff2のURLを正規表現で抽出 → Edge Runtimeでは該当なし
  2. satoriはwoff2を読めない(OTFかTTFのみ)

  3. Noto Sans JPのTTFは5MB以上 → Edge Runtimeのメモリ制限に引っかかる

対策

まずフォントなしで試す。satoriの最近のバージョンはデフォルトで日本語を描画可能。

それでもダメなら:

  • Google Fontsから直接TTF URLを取得して使う(CSSパースではなく)
  • サブセットフォントを使う(全文字不要なら軽量版を自前で用意)
  • ラベルを英語にして日本語はcompany名だけにする
// フォントなしでOK(satoriが内蔵フォントで描画)
return new ImageResponse(
  <div style={{ display: "flex", fontFamily: "sans-serif" }}>
    サイバーエージェント
  </div>,
  { width: 1200, height: 630 }
);

デバッグのベストプラクティス

1. エラーを可視化するエンドポイントを用意

// ?debug=1 でパラメータとフォント状態を確認
if (searchParams.get("debug")) {
  return new Response(JSON.stringify({
    company, salary, hourly,
    fontStatus: "loaded"
  }), { headers: { "content-type": "application/json" } });
}

2. ローカルで必ずdevサーバーテスト

Edge Runtimeのエラーはdevサーバーのターミナルにだけ出る。本番ではサイレント。

3. OGPチェッカーのキャッシュに注意

opengraph.xyzなどのチェッカーは壊れたレスポンスをキャッシュする。修正後は別のチェッカーで確認するか、キャッシュクリアする。

4. curlで直接確認

# 画像APIのレスポンスサイズ確認
curl -s -o /dev/null -w "status:%{http_code} size:%{size_download} time:%{time_total}s" \
  "https://your-site.vercel.app/api/og?d=..."

# HTMLからog:imageのURLを抽出
curl -s "https://your-site.vercel.app/share-page" | grep -oE 'content="[^"]*og[^"]*"'

まとめ

症状 原因 対策
display: flex未指定 空レスポンス/500 satoriの仕様 全divに付ける
&エスケープ 画像URL無効 Next.js HTML出力 Base64urlで単一パラメータ化
日本語フォント 500/メモリ超過 フォント未読込 or 巨大TTF フォントなしで試す or 直接URL

3つともエラーメッセージが出ない or 見えないのが共通点。動的OG画像を実装する際は、最初からデバッグエンドポイントを用意し、ローカルで必ずテストすることを推奨する。


今回の実装は TimeValue の開発中にハマった問題の解決記録です。ソースコードは GitHub で公開中。