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で & が & にエスケープされる
症状
- OG画像API自体はブラウザで直接開くと正常に画像を返す
- しかしSNSクローラーが画像を取得できず、OGPチェッカーで「OG Image URL appears to be invalid」と表示される
原因
Next.jsのgenerateMetadataでOG画像URLを返すと、HTMLレンダリング時に&が&にエスケープされる。
// 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&salary=300000&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画像には外部フォントが必要…と思いきや、実はフォントなしでも日本語は描画できる。
ただし以下の落とし穴がある:
-
Google Fonts CSSのレスポンスはUser-Agentで変わる
- ブラウザ: woff2形式を返す
- Edge Runtime / curl: ttf形式を返す
- CSSからwoff2のURLを正規表現で抽出 → Edge Runtimeでは該当なし
-
satoriはwoff2を読めない(OTFかTTFのみ)
-
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画像を実装する際は、最初からデバッグエンドポイントを用意し、ローカルで必ずテストすることを推奨する。
