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

結論 — 「片側だけ直る」は不具合ではなく、仕様どおりの動き
Next.js のメタデータは、レイアウトとページの間で浅く(shallowに)合成されます。つまり openGraph と twitter は、それぞれ独立した1個のキーとして扱われます。
ページ側で openGraph を書くと、親の openGraph は丸ごと置き換わります。一方で twitter を書かなければ、親の twitter がそのまま生き残ります。この非対称が「og だけ新しく、twitter だけ古い」の正体です。
症状の見分け方 — キャッシュのせいか、コードのせいか
最初にやることは、対象ページのHTMLを取って og:title と twitter: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. (同じルート上の複数のセグメントから書き出されたメタデータは、浅く合成される。重複するキーは順序にしたがって置き換えられる。)
私たちのサイトでは、ルートレイアウトが openGraph と twitter の両方にブランド共通の文言を持っていました。そこへ、ページ側が 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も直る、は成り立たない
もうひとつ、ページ側で title と description だけを書いた場合も要注意です。openGraph を書いていなければ、OGPは親の値のままで、ページのタイトルは反映されません。
実際、私たちのニュース一覧ページ(title と description のみ指定)は、og も twitter も親の値のままでした。og:url までルートのURLを指していました。
実測 — 本番14ページを機械で突き合わせた結果
2026-08-30 に、自社5ホスト(公式サイト・先勝・Lifefolio・Tsumu・メヤス)の代表14ページを取得し、og:* と twitter:* を1件ずつ突き合わせました。
| 状態 | ページ数 | 中身 |
|---|---|---|
| 説明文だけずれていた | 6 | twitter:description が親のブランド文言のまま |
| 説明文とタイトルの両方 | 2 | ブログ一覧・判例インデックス(ページ固有名がXでだけ消える) |
| OGPタグ自体が無かった | 2 | 別ホストの2ページ(カードが画像なしの簡易表示になる) |
| 完全に一致 | 4 | 記事ページ・アプリLPなど、twitter を明示していたページ |
つまり14ページ中10ページに何らかのずれがありました。ここで大事なのは、ずれ=即バグではないことです。Xだけ短いコピーを出す、という出し分けは正当な運用です。
判定が要るのは「意図した出し分けか、直し忘れか」の一点です。私たちの場合、ページ固有のタイトルがXでだけブランド名に戻っていた2ページは、明確な直し忘れでした。
見つけ方 — 目視をやめて、機械に列挙させる
このずれは、片側だけを見ている限り絶対に気づけません。OGPデバッガでプレビューを見て「直った」と判断した瞬間に、Xカードの確認が抜け落ちます。
そこで、公開中のHTMLを取って両者を突き合わせるだけの検査を1本置きました。落ちたら赤くなる形にして、リリース後に実行します。
- 検査したいURLの一覧を1か所にまとめる(本番の代表ページで十分)
- 各URLのHTMLを取得し、
og:titleog:descriptionog:imageとtwitter:*を抜き出す - 画像URLはビルドごとに変わるクエリを落とし、パスだけで比べる
- 値が食い違うURLと、
twitter:cardが無いURLを列挙する - 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を置く - 「直したはず」を目視で確認する
起きない書き方
openGraphとtwitterを必ず2つ揃えて書く- 親には
cardとsiteだけ置き、文言は各ページに持たせる - 公開中のHTMLを機械で突き合わせる
①各ページで両方を書く。いちばん確実です。タイトルと説明文を変数に入れ、openGraph と twitter の両方へ同じ値を渡します。
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 には card や site(アカウント名)といった、全ページで同じで構わない項目だけを残します。こうすると、ページが twitter を書き忘れても文言は og:* へのフォールバックで拾われ、少なくとも「別ページの文言が出る」事故はなくなります。
それでも直らないときに、はじめてキャッシュを疑う
HTMLの両方が正しくなったら、そこでようやく外部のキャッシュの出番です。XもFacebookも、一度取得したカードをしばらく保持します。
- X: Cards のドキュメント(新しいタブで開く)にある手順でカードの取得状況を確認する
- Facebook / Slackなど: シェアデバッガー(新しいタブで開く)で「もう一度スクレイピング」を実行する
順序を守るだけで、原因の切り分けが速くなります。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:title と twitter:title を見比べてください。値が違えばコード側、同じならキャッシュ側です。値が違うままキャッシュを消しても、次の取得でまた同じ古い値が読まれます。
Q. `title` と `description` だけ書けば、OGPにも反映されますか?
親レイアウトが openGraph を持っていない場合は反映されます。しかし親が openGraph を定義していると、子は openGraph を書かないかぎり親の値を継承するため、ページのタイトルは出ません。公式ドキュメントの「Inheriting fields」の例が、まさにこの動きを示しています。
Q. 検査はどれくらいの頻度で回せばよいですか? デプロイのたびに全ページを見る必要はありません。私たちは代表ページ(トップ・一覧・記事1本・各プロダクトのLP)に絞り、リリース後と週次の2回だけ実行しています。テンプレート単位で同じ不具合が出るので、テンプレートごとに1ページ見れば足ります。
Q. `og:image` と `twitter:image` も揃えるべきですか? 基本は同じで構いません。ただし画像URLにビルドごとのクエリが付く仕組みだと、文字列比較では毎回「違う」と判定されてしまいます。比較するときはクエリを落とし、パスだけで突き合わせると誤検知が消えます。
コメント
#Next.js#開発の裏側#AI開発