opengraph-image を1枚置いても、OG 画像が付いたのはそのページだけだった
Next.js 16 のサイトに OG 画像を入れた。ルート直下に app/opengraph-image.tsx を1枚置き、記事ページには記事タイトルを差し込む動的生成を足した。ビルドは通り、画像も生成され、URL を叩けば PNG が返ってくる。それでも og:image の meta タグが出ていたのは、8ページ中2種類だけだった。
OG 画像が付いたのは2種類のページだけだった
実装した直後、本番ビルドをローカルで起動して全ページの og:image を並べた。ブラウザで1ページずつ開くのではなく、curl で一覧にする。
# 全ページの og:image を一覧にする。1ページずつブラウザで見ない
$ for p in / /blog /about /contact /privacy /tokushoho \
/blog/topics/architecture /blog/home-server-architecture-overview; do
printf "%-45s " "$p"
curl -s "https://example.com$p" | grep -oE 'property="og:image" content="[^"]*"' | head -1
done結果はきれいに分かれた。
/ property="og:image" content="…/opengraph-image?4f85c672"
/blog ← 何も出ない
/about ← 何も出ない
/contact ← 何も出ない
/privacy ← 何も出ない
/tokushoho ← 何も出ない
/blog/topics/architecture property="og:image" content="…/opengraph-image?3cfc8b12"
/blog/home-server-architecture-overview
property="og:image" content="…/opengraph-image?daeb7bb4"画像が付いたのは、app/opengraph-image.tsx を置いた「/」と、app/blog/[slug]/opengraph-image.tsx を置いた記事ページ、そして記事タグのページだけ。つまりファイルを置いたセグメントだけが付いていた。
ファイル規約はセグメントを越えない
ここで前提が間違っていたことが分かる。ルート直下に1枚置けば、レイアウトの metadata と同じように子ルートへ降りていくものだと思っていた。降りていかない。opengraph-image は、そのファイルが属するルートセグメントのページにしか適用されない。
紛らわしいのは、隣にある metadata オブジェクトのほうは継承されることだ。レイアウトに書いた title や openGraph.siteName は子ページに降りてくる。同じ「メタデータ」という言葉で括られているのに、ファイル規約だけ挙動が違う。
metadata に書いた images は無視される
継承されないなら明示すればいい、と考えて、共通の metadata ヘルパに画像を直接書いた。全ページがこのヘルパを通るので、これで漏れはなくなるはずだった。
// これを書いても og:image は1つも増えなかった。
// opengraph-image.tsx が1つでも存在すると、こちらは読まれない。
export function pageSeo({ title, description, path }: PageSeoInput): Metadata {
return {
openGraph: {
// …
images: [{ url: "/opengraph-image", alt: title, width: 1200, height: 630 }],
},
};
}ビルドし直して同じ curl を流したが、出力は1行も変わらなかった。og:image は増えていない。
決め手になったのは記事ページの出力だった。記事にも同じヘルパから画像 URL を渡していたのに、実際に出ていたのはファイル規約が生成した URL のほう(末尾にキャッシュ用のハッシュが付いている)だった。つまりopengraph-image のファイルがアプリ内に1つでも存在すると、metadata 側の openGraph.images は読まれない。ファイル規約が設定より強く、しかも「そのファイルが効かないページ」では代わりに何も出ない。
ファイル規約と設定のどちらでも書ける機能は、混ぜると弱いほうが黙って捨てられる。エラーも警告も出ないので、書いたコードが実行されていないことに気づく手段が結果の観測しかない。
再エクスポートだけのファイルは紐づかない
設定側が使えないと分かったので、必要なセグメントすべてに opengraph-image.tsx を置く方針にした。ただし画像の中身は全ページ同じなので、実装は1箇所に置いて各ファイルからは再エクスポートするだけにした。1行で済む。
ビルドは通り、/about/opengraph-image のようなルートもきちんと生成された。実際に叩くと 200 で PNG が返る。それでも og:image の meta タグは出なかった。
// これはダメ。画像のルート自体は生成されるのに、meta タグは出ない
export { default, alt, size, contentType } from "@/components/seo/site-og-image";
// これは通る。ローカルの束縛として宣言し直す
import SiteOgImage, {
alt as siteAlt,
contentType as siteContentType,
size as siteSize,
} from "@/components/seo/site-og-image";
export const alt = siteAlt;
export const size = siteSize;
export const contentType = siteContentType;
export default SiteOgImage;ローカルの const として宣言し直したら、全ページに出た。ビルド時にこのファイルの alt や size を静的に読み取る処理が、export … from の再エクスポートを追えていないのだと思われる。
この段階が一番たちが悪かった。画像のルートは生成され、URL を直接叩けば 200 が返るので、設定できているように見える。実際に欠けているのは HTML の中の1行だけで、そこを見に行かない限り成功しているとしか判断できない。
大文字だけ書体が混ざった
画像が全ページに付いたので、次は中身を見た。日本語のタイトルは正しく描けている。next/og に同梱されるフォントは日本語グリフを持たないため、描画する文字だけを Google Fonts からサブセットで取得している。それは意図どおり動いていた。
崩れていたのは英字のほうだった。ラベルの "INDEPENDENT SOFTWARE" と "ARCHITECTURE" が、1つの単語の中で太さの違う2種類の書体で描かれている。原因はサブセットの要求内容だった。CSS では textTransform: uppercase を使っているのに、フォントに要求していたのは変換前の文字列だったので、大文字のグリフが含まれていない。含まれていない字だけ既定のフォントにフォールバックし、そこだけ別の書体で描かれていた。
/**
* textTransform: uppercase で描く箇所があるため、大文字も併せて要求する。
* これを忘れると大文字だけサブセットから漏れ、その字だけ別のフォントで描かれて
* 1つの単語の中で書体が混ざる。
*/
function subsetCharacters(text: string): string {
return [...new Set(`${text}${text.toUpperCase()}`)].join("");
}
const cssUrl =
"https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700" +
`&text=${encodeURIComponent(subsetCharacters(text))}`;Set で重複を落としているのは URL を短く保つためで、機能上の意味はない。この修正のあと、本番で生成された PNG をダウンロードして目で確認した。画像は壊れずに描画されてしまうので、生成できたことは正しく描けたことの証明にならない。
フォントの取得失敗でデプロイを止めない
サブセットの取得は外部への HTTP リクエストで、これはビルド中に走る。ここで検討して捨てた選択肢が1つある。取得に失敗したときに throw して、ビルドを落とすかどうか。
エラーを握り潰さないという原則からは throw が正しい。それでも警告に落とすほうを選んだ。落とす対象がフォント配信の一時障害だからだ。この設計で throw すると、自分のコードが1行も変わっていないのにデプロイが失敗する状態を、外部サービスの都合で作れてしまう。OG 画像が1枚欠けることと、サイトの更新経路が止まることを比べれば、前者のほうが軽い。
} catch (error) {
// ここで throw するとフォント配信の一時障害でデプロイ全体が落ちる。
// OG 画像の欠けはサイトの停止より軽いので、警告に落として描画は続行する。
console.warn("[og] 日本語フォントを読み込めませんでした:", error);
return null;
}ログには残す。静かに握り潰すのとは違う。「失敗しても続行する」を選ぶなら、失敗したことが後から分かる形にしておかないと、次に見たときには単なる仕様に見えてしまう。
目視をやめて curl で全ページ見る
3つの罠は性質が違うが、見つけ方は全部同じだった。生成された HTML から og:image を抜いて、全ページ分を1つの出力に並べる。
- opengraph-image は置いたセグメントにしか効かない。ルート直下の1枚は「/」にしか付かず、子ルートは何も継承しない。
- ファイルが1つでも存在すると、metadata の openGraph.images は無視される。ファイル規約と設定を混ぜると、設定側が黙って捨てられる。
- 再エクスポートだけのファイルは metadata に紐づかない。画像のルートは生成されるので、URL を直接叩くと 200 が返り、正しく設定できたように見える。
どれも「ビルドが通った」「ルートが生成された」「URL が 200 を返した」をいくら積み上げても検出できない。確認すべきなのは配信される HTML に目的のタグが入っているかだけで、それ以外はすべて途中経過だ。
そして同じ確認を本番でもう一度やる。ローカルの本番ビルドで通っていても、フォントの取得はビルド環境で走るので、結果が同じになる保証はない。実際に本番の PNG をダウンロードして開くまでは、確認したことにしていない。
まとめ
フレームワークが「ファイルを置くだけ」と「設定に書く」の2通りを用意しているとき、その2つは対等ではないことがある。今回は前者が後者を上書きし、しかも前者が効かない範囲では代わりに何も出なかった。ドキュメントを読んで動くつもりで書いたコードが、実行すらされていなかった。
こういう食い違いは、型でもテストでも捕まらない。捕まえられるのは、成果物そのものを機械で読んで並べることだけだった。8行の for ループで足りる作業を、ブラウザで1ページずつ開いて済ませていたら、og:image が5ページ分欠けたまま公開していたと思う。