本文へスキップ
開発日記読了 約10

OGPは直したのにXのカードだけ古いまま——Next.jsで片側しか直らない理由と、本番14ページを機械照合した実測

OGP(SNSでURLを貼ったときに出るカードの元データ)を直したのに、X(旧Twitter)のカードだけ古い文言のまま——このとき疑うべきはキャッシュではなく、コードのほうです。Next.js の Metadata API は openGraphtwitter を別々のかたまりとして扱い、ページ側で openGraph だけ書くと twitter は親レイアウトの値がそのまま残ります。私たちは同じ穴を2つのプロダクトで2回踏んだので、目視をやめて公開中のHTMLを機械で突き合わせる検査を作りました。その結果、自社の本番14ページのうち10ページでOGPとXカードの中身がずれていました(2026-08-30 実測)。

OGP直してもXだけ直らない — 14ページ照合、10ページでズレを検出

結論 — 「片側だけ直る」は不具合ではなく、仕様どおりの動き

Next.js のメタデータは、レイアウトとページの間で浅く(shallowに)合成されます。つまり openGraphtwitter は、それぞれ独立した1個のキーとして扱われます。

ページ側で openGraph を書くと、親の openGraph は丸ごと置き換わります。一方で twitter を書かなければ、親の twitterそのまま生き残ります。この非対称が「og だけ新しく、twitter だけ古い」の正体です。

症状の見分け方 — キャッシュのせいか、コードのせいか

最初にやることは、対象ページのHTMLを取って og:titletwitter:title を並べて見ることだけです。ブラウザの検証ツールでも、次の1行でも構いません。

curl -s https://example.com/blog | grep -oE '<meta[^>]*(og:|twitter:)[^>]*>'

ここで2つの値が違っていたら、コードの問題です。Xのキャッシュを消しても、次に取りに来たときに同じ古い値がまた読まれます。

逆に2つの値が一致しているのにカードが古いなら、そのときこそキャッシュを疑う場面です。順序を逆にすると、キャッシュ削除を何度も試して原因にたどり着けません。

なぜ起きるのか — 親レイアウトの twitter が子ページに残る仕組み

Next.js の公式ドキュメントは、この挙動を「継承(Inheriting fields)」として明記しています。ページ側で title を変えても、openGraph を書かなければ og のタイトルは親のままになる、という例が載っています(generateMetadata / Merging(新しいタブで開く))。

Metadata objects exported from multiple segments in the same route are shallowly merged together to form the final metadata output of a route. Duplicate keys are replaced based on their ordering. (同じルート上の複数のセグメントから書き出されたメタデータは、浅く合成される。重複するキーは順序にしたがって置き換えられる。)

私たちのサイトでは、ルートレイアウトが openGraphtwitter の両方にブランド共通の文言を持っていました。そこへ、ページ側が openGraph だけを上書きしていたのです。

// src/lib/seo.ts(ルートレイアウトが読む共通メタデータ)
openGraph: { title: "JurisCode.AI — 「できない」を、「できる」へ。", /* … */ },
twitter:   { card: "summary_large_image", title: "JurisCode.AI — 「できない」を、「できる」へ。", /* … */ },

// src/app/blog/page.tsx(ブログ一覧ページ)
export const metadata: Metadata = {
  title: "ブログ",
  openGraph: { title: "ブログ|JurisCode.AI", /* … */ },   // ← ここだけ上書きした
  // twitter を書いていない = 親のブランド文言がそのまま出る
};

結果として、ブログ一覧ページのXカードには、ページ名ではなくブランドのキャッチコピーが出続けていました

title を書けばOGPも直る、は成り立たない

もうひとつ、ページ側で titledescription だけを書いた場合も要注意です。openGraph を書いていなければ、OGPは親の値のままで、ページのタイトルは反映されません。

実際、私たちのニュース一覧ページ(titledescription のみ指定)は、og も twitter も親の値のままでした。og:url までルートのURLを指していました。

実測 — 本番14ページを機械で突き合わせた結果

2026-08-30 に、自社5ホスト(公式サイト・先勝・Lifefolio・Tsumu・メヤス)の代表14ページを取得し、og:*twitter:* を1件ずつ突き合わせました。

状態ページ数中身
説明文だけずれていた6twitter:description が親のブランド文言のまま
説明文とタイトルの両方2ブログ一覧・判例インデックス(ページ固有名がXでだけ消える)
OGPタグ自体が無かった2別ホストの2ページ(カードが画像なしの簡易表示になる)
完全に一致4記事ページ・アプリLPなど、twitter を明示していたページ

つまり14ページ中10ページに何らかのずれがありました。ここで大事なのは、ずれ=即バグではないことです。Xだけ短いコピーを出す、という出し分けは正当な運用です。

判定が要るのは「意図した出し分けか、直し忘れか」の一点です。私たちの場合、ページ固有のタイトルがXでだけブランド名に戻っていた2ページは、明確な直し忘れでした。

見つけ方 — 目視をやめて、機械に列挙させる

このずれは、片側だけを見ている限り絶対に気づけません。OGPデバッガでプレビューを見て「直った」と判断した瞬間に、Xカードの確認が抜け落ちます。

そこで、公開中のHTMLを取って両者を突き合わせるだけの検査を1本置きました。落ちたら赤くなる形にして、リリース後に実行します。

OGPとXカードの非対称を機械で見つける手順
  1. 検査したいURLの一覧を1か所にまとめる(本番の代表ページで十分)
  2. 各URLのHTMLを取得し、og:title og:description og:imagetwitter:* を抜き出す
  3. 画像URLはビルドごとに変わるクエリを落とし、パスだけで比べる
  4. 値が食い違うURLと、twitter:card が無いURLを列挙する
  5. 1件でも見つかったら終了コード1で落とす(CIや定期実行に載せられる)

抜き出すときの注意は1つだけです。<meta> の属性の並び順は環境によって変わるので、property="og:title" が先にくる形と content="…" が先にくる形の両方を拾う必要があります。

function meta(html, kind, prop) {
  const attr = kind === "og" ? "property" : "name";
  const key = `${kind === "og" ? "og" : "twitter"}:${prop}`;
  const a = html.match(new RegExp(`<meta[^>]+${attr}="${key}"[^>]*content="([^"]*)"`, "i"));
  if (a) return a[1];
  const b = html.match(new RegExp(`<meta[^>]+content="([^"]*)"[^>]*${attr}="${key}"`, "i"));
  return b ? b[1] : null;
}

この検査を書いてから実行するまでにかかった時間は、実測で30分ほどでした。目視で2回見落とした穴が、1回の実行で10件出てきたことになります。

直し方 — 3つの型から選ぶ

直し方は3通りあり、サイトの性格で選び分けます。私たちは記事ページで①、共通部で③を使っています。

直し忘れが起きる書き方

  • ページごとに openGraph だけ書く
  • 親レイアウトに固定文言の twitter を置く
  • 「直したはず」を目視で確認する

起きない書き方

  • openGraphtwitter を必ず2つ揃えて書く
  • 親には cardsite だけ置き、文言は各ページに持たせる
  • 公開中のHTMLを機械で突き合わせる

①各ページで両方を書く。いちばん確実です。タイトルと説明文を変数に入れ、openGraphtwitter の両方へ同じ値を渡します。

const title = post.title;
const description = post.description;
return {
  title: post.seoTitle ?? title,
  description,
  openGraph: { type: "article", title, description /* … */ },
  twitter: { card: "summary_large_image", title, description },
};

②共通部分を変数に切り出す。公式ドキュメントが勧めている方法で、画像など「共有したい入れ子の項目」を別ファイルに置き、各ページで展開します。

③親レイアウトには文言を置かないtwitter には cardsite(アカウント名)といった、全ページで同じで構わない項目だけを残します。こうすると、ページが twitter を書き忘れても文言は og:* へのフォールバックで拾われ、少なくとも「別ページの文言が出る」事故はなくなります。

それでも直らないときに、はじめてキャッシュを疑う

HTMLの両方が正しくなったら、そこでようやく外部のキャッシュの出番です。XもFacebookも、一度取得したカードをしばらく保持します。

順序を守るだけで、原因の切り分けが速くなります。HTMLが違うならコード、HTMLが同じならキャッシュ。この2択に落とすのが、この記事でいちばん持ち帰ってほしい部分です。

検査を作ったら、想定していない穴も出てきた

副産物として、別ホストの2ページにはそもそもOGPタグが1つも無いことが分かりました。カードは出るものの、画像も説明文も無い簡易表示になります。

これは「片側だけ直る」問題とは別の欠陥です。それでも同じ検査で見つかったのは、目視のチェックリストが人の記憶の順番でしか回らないのに対し、機械の検査は毎回同じ順番で全ページを見るからです。

私たちは自動化の失敗を何度か記事にしてきました。日付が静かに1日ずれていた件(toISOString() で日付が1日ずれる)も、エラーを出さずに壊れていた件(エラーを出さずに壊れる自動化)も、共通するのは「定常的な誤りは、変動する誤りより見つけにくい」ことです。毎回同じ間違ったカードが出ていると、それは「そういうもの」に見えてしまいます。

検索結果の見え方も同じ構造で、規格を決めて機械で見張る形に変えました(検索結果でタイトルが切れる問題を直した話)。新しい欠陥を見つけたら、直す前にまず検査を足す——これを私たちの運用ルールにしています。

「開発環境では正常なのに本番だけ壊れる」という点では、Turbopack で Google Fonts が読めない対処法も同じ型でした。手元で再現しないので、本番を実測しないと気づけません。

よくある質問

Q. `twitter:title` を書かなければ、Xは `og:title` を読んでくれるのでは? そのとおりです。twitter:* が存在しないときに限り、Xは og:* を代わりに使います。問題は、親レイアウトが twitter を定義していると、子ページのHTMLにも twitter:*必ず出力される点です。フォールバックが働く条件(タグが無いこと)が満たされないため、親の文言がそのまま表示されます。

Q. カードが古いのはXのキャッシュのせいだと言われました。 両方あり得ます。切り分けは簡単で、そのページのHTMLを取って og:titletwitter:title を見比べてください。値が違えばコード側、同じならキャッシュ側です。値が違うままキャッシュを消しても、次の取得でまた同じ古い値が読まれます。

Q. `title` と `description` だけ書けば、OGPにも反映されますか? 親レイアウトが openGraph を持っていない場合は反映されます。しかし親が openGraph を定義していると、子は openGraph を書かないかぎり親の値を継承するため、ページのタイトルは出ません。公式ドキュメントの「Inheriting fields」の例が、まさにこの動きを示しています。

Q. 検査はどれくらいの頻度で回せばよいですか? デプロイのたびに全ページを見る必要はありません。私たちは代表ページ(トップ・一覧・記事1本・各プロダクトのLP)に絞り、リリース後と週次の2回だけ実行しています。テンプレート単位で同じ不具合が出るので、テンプレートごとに1ページ見れば足ります。

Q. `og:image` と `twitter:image` も揃えるべきですか? 基本は同じで構いません。ただし画像URLにビルドごとのクエリが付く仕組みだと、文字列比較では毎回「違う」と判定されてしまいます。比較するときはクエリを落とし、パスだけで突き合わせると誤検知が消えます。

コメント

コメントは即時公開されます。不適切な内容は予告なく削除します。

#Next.js#開発の裏側#AI開発