Architecture2026.08.12 · 9 min read

opengraph-image を1枚置いても、OG 画像が付いたのはそのページだけだった

NCP認証基盤・クラウドインフラ

Next.js 16 のサイトに OG 画像を入れた。ルート直下に app/opengraph-image.tsx を1枚置き、記事ページには記事タイトルを差し込む動的生成を足した。ビルドは通り、画像も生成され、URL を叩けば PNG が返ってくる。それでも og:image の meta タグが出ていたのは、8ページ中2種類だけだった。

OG 画像が付いたのは2種類のページだけだった

実装した直後、本番ビルドをローカルで起動して全ページの og:image を並べた。ブラウザで1ページずつ開くのではなく、curl で一覧にする。

全ページの og:image を並べる
# 全ページの 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 オブジェクトのほうは継承されることだ。レイアウトに書いた titleopenGraph.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 として宣言し直したら、全ページに出た。ビルド時にこのファイルの altsize を静的に読み取る処理が、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ページ分欠けたまま公開していたと思う。

Next.jsOGPnext/ogSEO