p4ni.

チュートリアル

Astro の構造化データ: 2026年に Google がまだ読む JSON-LD

· 読了まで約23分

目次

構造化データは、SEO の作業のなかでほとんど唯一、結果が決まりきっているものです。JSON オブジェクトを吐き、Google がそれを解析し、そのページが検索機能の条件を満たすか満たさないかが決まる。ランキング要因を推し量る余地はありません。

厄介なのは、世に出ているチュートリアルの多くが、もう存在しない Google を前提に書かれていることです。HowTo マークアップは2023年に廃止されました。FAQPage は政府系・医療系サイト向けに細々と生き延びていましたが、2026年5月7日に FAQ リッチリザルトが Google 検索から完全に消え、翌月にはドキュメントごと削除されています。コース情報、推定給与、学習動画、特別お知らせ、車両リスティングも2025年に同じ道をたどり、練習問題が2026年1月に続きました。削除の記録はひととおり残っていて、Google 自身は検索結果ページの簡素化と説明しています。

死んだ型をマークアップしても、valid な JSON-LD が1つ増えるだけで、得られるものはゼロです(害もありません。Google は既存のマークアップを剥がす必要はないと明言していますし、他の検索エンジンはまだ読んでいるかもしれません。新しく書かなければいいだけです)。

そこで逆側から始めます。まだ何かをしてくれる型は何か。そしてそれを、ページごとに JSON を手書きせずに Astro から出力するにはどうするか。

以下の順で進めます。

  1. 2026年時点でまだ効く型と、もう効かない型
  2. ページごとの <script> ではなく、ベースレイアウトに JSON-LD の口を1つ作る
  3. content collections のスキーマから BlogPosting を組み立てる(情報源をひとつに)
  4. Person / Organization / WebSite を一度だけ定義し、@id で参照する
  5. BreadcrumbList、見た目が実際に変わる唯一の型
  6. 検証の方法と、Search Console が教えてくれること・くれないこと

2026年にマークアップする価値がある型

実際に得られるもの
BreadcrumbList✅ URL パスの代わりに表示されるパンくずを制御できる(デスクトップ)
Product✅ 価格・在庫・レビューの星。フィールドが揃っていれば
Article / BlogPosting⚠️ 専用のリッチリザルトは無い。ただし著者と日付はこれで読まれる
Organization / WebSite⚠️ ナレッジパネル、ロゴ、そしてサイト名
HowTo❌ 2023年に廃止
FAQPage❌ 2026年5月に検索から完全に削除

このうち3つには補足が要ります。

BlogPosting は、みんなが投資しすぎている型です。 Article のリッチリザルトのカードは存在しません。これを入れても検索結果の見た目は変わりませんし、Search Console の拡張レポートに Article という項目が現れることもありません。Google の説明は、見出し・画像・日付の理解を助けるというもの。本当ではありますが、目には見えません。トップニュースや Discover への入場券でもありません。トップニュースにマークアップ要件は無いこと、Discover に特別なタグや構造化データは不要なことを、Google ははっきり書いています(Discover が実際に求めるのは max-image-preview:large と幅1200px 以上の画像です)。BlogPosting がしてくれるのは、見出し・画像・著者・日付を推測させずに読ませることだけ。出す価値はあるし正確に保つべきですが、花火は上がりません。

BreadcrumbList は、逆にみんなが飛ばす型です。 そしてここに挙げたなかで唯一、検索結果の見え方を確実に変えます。URL のパスだけでは階層が伝わらないサイトでは、なおさらです。Google はマークアップが無くても URL からパンくずを推測しますが、そこに何と表示するかを決められるのがこのマークアップです。デスクトップ限定の機能である点は覚えておいてください。

WebSite は、古いチュートリアルのまま実装されがちな型です。 サイトリンク検索ボックスのために SearchAction を足せと書いてあるガイドを見つけたら、そっと閉じてください。この機能は2024年11月に Google 検索から削除されています。WebSite 自体は今も意味がありますが、理由が別のところにあります(後述)。

JSON-LD はレイアウトに一度だけ通す

Astro のプロジェクトでよく見かける失敗は、<script type="application/ld+json"> を各ページのテンプレートにコピペすることです。ベースレイアウトに口を1つ用意して、オブジェクトはページから渡します。

---
// src/layouts/BaseLayout.astro
interface Props {
  title?: string;
  description?: string;
  ogType?: 'website' | 'article';
  /** schema.org の構造化データ。<head> に JSON-LD として出力する。 */
  jsonLd?: Record<string, unknown>;
}

const { title, description, ogType = 'website', jsonLd } = Astro.props;
---

<head>
  <!-- …title, meta, canonical… -->
  {jsonLd && (
    <script
      type="application/ld+json"
      set:html={JSON.stringify(jsonLd).replace(/</g, '\\u003c')}
    />
  )}
</head>

この1行には注意点が3つあります。最初のひとつは、自分でぶつかるまでどこにも書かれていなかった罠です。

  • 子要素として {JSON.stringify(...)} を書かず、set:html を使う。 <script> は raw text 要素なので、Astro は中身をテンプレートとして扱いません。式を子要素として書くと、{JSON.stringify(jsonLd)} という文字列がそのまま HTML に出力されます。ビルドエラーも出ず、静かにそうなります。通常の要素であれば式は評価された上で HTML エスケープされますが、それはそれで JSON が別の壊れ方をします。set:html は値をそのまま書き込むための逃げ道です。

  • raw で書くということは、エスケープの責任を自分で持つということ。 上のコードで .replace を挟んでいるのはそのためです。JSON.stringify< をエスケープしないので、タイトルに </script> という文字列を含む記事があれば、そこでブロックが閉じ、残りの JSON はマークアップとして解析されます。<\u003c にエスケープしても JSON としては valid で、解析結果も同一、コストもゼロです。

  • テンプレートリテラルではなくオブジェクトを JSON.stringify に渡す。 テンプレート文字列のなかに JSON を手書きすると、末尾のカンマひとつでブロック全体が静かに死にます。

content collections から BlogPosting を組み立てる

ここがそのままプロジェクトに持ち込める部分です。Astro の content collections は型が付いているので、記事の frontmatter がそのまま構造化データの情報源になります。同期を取るべき二つ目の場所を作らずに済みます。

まずスキーマ。構造化データが求めるフィールドを入れておきます。

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/[^_]*.{md,mdx}' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    category: z.enum(['tutorial', 'comparison', 'build-in-public']),
    tags: z.array(z.string()).default([]),
    // 他所へのクロスポストがここを指す。正典がこのサイトで無いときだけ設定する。
    canonicalUrl: z.string().url().optional(),
    ogImage: z.string().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

スキーマの細部が2つ、下流で効いてきます。tags.default([]) はフィールドが常に配列であることを保証するので、タグを書いていない記事でも後述の条件付きスプレッドが落ちません。z.coerce.date() のほうは変換というより保険です。クォートしていない 2026-07-29 は YAML の frontmatter パーサーがすでに Date に変換していますが、クォートすると文字列で返ってきます。coerce はどちらも正規化してくれるので、下流で何も考えずに .toISOString() を呼べます。schema.org が求めるのは ISO 8601 で、日付だけの 2026-07-29 はタイムゾーンについて何も語っていません。

そのうえでページがノードを組み立てます。以下はこのサイトで動いているコードから i18n まわりを抜いたものです(実物は src/pages/[...locale]/blog/[slug].astro で、inLanguagearticleSection が加わります)。@id による参照と graph() ラッパーは次の節の主題なので、いまは読み飛ばしてください。

---
// src/pages/blog/[slug].astro
import { SITE_URL } from '../../consts';
import { ORGANIZATION_ID, PERSON_ID, WEBSITE_ID, baseNodes, graph } from '../../schema';

const { post } = Astro.props;
const { title, description, pubDate, updatedDate, tags, canonicalUrl } = post.data;

const ogImage = post.data.ogImage ?? `/og/${post.id}.png`;
const canonical = canonicalUrl ?? new URL(`/blog/${post.id}/`, Astro.site).href;

const jsonLd = graph(
  {
    '@type': 'BlogPosting',
    '@id': `${canonical}#article`,
    headline: title,
    description,
    url: canonical,
    mainEntityOfPage: canonical,
    image: new URL(ogImage, SITE_URL).href,
    datePublished: pubDate.toISOString(),
    dateModified: (updatedDate ?? pubDate).toISOString(),
    author: { '@id': PERSON_ID },
    publisher: { '@id': ORGANIZATION_ID },
    isPartOf: { '@id': WEBSITE_ID },
    ...(tags.length > 0 && { keywords: tags.join(', ') }),
  },
  ...baseNodes
);
---

<BaseLayout {title} {description} {canonicalUrl} {pubDate} {updatedDate} {ogImage} ogType="article" {jsonLd}>
  <!-- … -->
</BaseLayout>

このなかで間違えやすいものが4つあります。

urlmainEntityOfPage<link rel="canonical"> と一致させる。 canonicalUrl がレイアウトにも渡っていることに注目してください。canonical タグと JSON-LD が同じ場所を指し続けるのはこれのおかげです。クロスポストしていて canonical が外部を指すなら、構造化データも同じ先を指す必要があります。2箇所で矛盾したことを言うのは、フィールドを省くより悪い。mainEntityOfPage は Article の推奨プロパティに入っていないので、これは要件というより一貫性の問題ですが、一貫していないほうが、かえって混乱のもとになります。

image は絶対 URL にする。 Google の実際の要件は URL がクロール可能かつインデックス可能であることです。相対パスの /og/my-post.png も技術的にはページのベース URL に対して解決されますが、脆いし、ツール側の扱いを間違えやすい。絶対形式で出しておきます。記事ごとの画像をビルド時に生成しているなら、その手順はAstro と Satori で OG 画像を自動生成するに書きました。同じパスが og:image タグとこのフィールドの両方を賄います。

dateModified のフォールバック先は今日ではなく datePublished テンプレートによってはここに new Date() を入れているものがあり、それは「ビルドのたびに全ページが更新された」と Google に伝えることになります。公開日に落とすほうが正直で、値も安定します。

条件付きのフィールドはスプレッドで足し、空の値を出さない。 JSON.stringify は値が undefined のプロパティを落とすので、うっかり書いた author: undefined は勝手に消えます。ただし空文字列は消えません。keywords: ''"keywords":"" として出力され、これは「このフィールドは空だ」と明示的に宣言している状態です。何も主張しないのとは違います。...(cond && { field }) なら丸ごと省けます。

Person と Organization と WebSite の役割分担

ひとり運営のサイト向けに短くまとめます。

  • Person は著者です。同じ人物であることを他所で裏付けるプロフィール(GitHub や個人サイト)を指す url を付けてください。url の無い名前はエンティティではなく、ただの文字列です。
  • Organization は発行者、つまりサイトそのものです。ひとりでも問題ありません。サイトが発行者で、あなたが著者という関係です。Google が表示する logo の判断にも効きます。
  • WebSite の今日的な用途はサイト名です。nameurl の組み合わせは、検索結果の URL の上に出るラベルを決めるときに Google が読む主要なシグナルになります。これは目に見える変化で、古いチュートリアルの SearchAction 側が無用になった今も WebSite を残す理由は、ここにあります。

マークアップにできないこともひとつ。サイトリンクは完全に自動で、どんな構造化データも影響しません。

この3つはどのページでも同じエンティティを指します。@graph@id はまさにこのために存在します。モジュールに一度だけ定義し、安定した @id を与えて、ページからはフィールドを繰り返さずに参照します。

// src/schema.ts
type Node = Record<string, unknown>;

export const PERSON_ID = `${SITE_URL}/about/#person`;
export const ORGANIZATION_ID = `${SITE_URL}/#organization`;
export const WEBSITE_ID = `${SITE_URL}/#website`;

export const person: Node = {
  '@type': 'Person',
  '@id': PERSON_ID,
  name: AUTHOR.name,
  url: `${SITE_URL}/about/`,
  knowsAbout: ['Astro', 'Cloudflare Workers', 'Static site generation', 'Technical SEO'],
  // これがあるから、GitHub や Gumroad のプロフィールと同一人物として束ねられる。
  sameAs: [AUTHOR.github, AUTHOR.gumroad, AUTHOR.astroBuildProfile],
};

export const organization: Node = {
  '@type': 'Organization',
  '@id': ORGANIZATION_ID,
  name: SITE_TITLE,
  url: `${SITE_URL}/`,
  founder: { '@id': PERSON_ID },
  sameAs: [AUTHOR.github, AUTHOR.gumroad],
};

export const website: Node = {
  '@type': 'WebSite',
  '@id': WEBSITE_ID,
  name: SITE_TITLE,
  url: `${SITE_URL}/`,
  publisher: { '@id': ORGANIZATION_ID },
};

/** 全ページ共通のノード。最後に展開して、ページ固有のノードが先に来るようにする。 */
export const baseNodes: Node[] = [website, organization, person];

/** ページが出力する単一のグラフにノードをまとめる。 */
export function graph(...nodes: Node[]): Node {
  return { '@context': 'https://schema.org', '@graph': nodes };
}

ここから来る帰結が2つあります。@id はフラグメント付きの絶対 URL で、ノード同士を結び付けている唯一の紐です。タイポしても例外は飛ばず、宙に浮いた参照ができるだけなので、テンプレートに文字列リテラルを書かず、エクスポートした定数を経由させてください。もうひとつ、これらのエンティティはトップページだけでなく全ページに載ります。重複ではありません。同じ @id は「同一のエンティティをもう一度説明している」という宣言だからです。

小規模サイトで割に合わないのは、テンプレートごとにこのグラフを手で組むことと、体裁を整えるために型を発明することです。モジュール1つと graph() の呼び出し1回。このパターンはこれで全部です。

検索結果の見え方を実際に変える型なので、ここは全部載せます。ヘルパーを用意して、ページ側は ListItem オブジェクトを手書きせず、名前とパスの組で経路を書けるようにします。

// src/schema.ts
/**
 * [名前, パス] の組から BreadcrumbList を作る。例:
 *   breadcrumb([['Home', '/'], ['Articles', '/blog/'], [title]])
 * 最後の項目は現在のページなので、Google のガイダンスどおり `item` を省く。
 */
export function breadcrumb(items: Array<[string, string?]>): Node {
  return {
    '@type': 'BreadcrumbList',
    itemListElement: items.map(([name, path], i) => ({
      '@type': 'ListItem',
      position: i + 1,
      name,
      ...(path ? { item: new URL(path, SITE_URL).href } : {}),
    })),
  };
}

記事ページから呼べば、経路は同じグラフのノードが1つ増えるだけです。

---
// src/pages/blog/[slug].astro
const jsonLd = graph(
  article,
  breadcrumb([['Home', '/'], ['Articles', '/blog/'], [title]]),
  ...baseNodes
);
---

Google に使ってもらうには ListItem が2つ以上必要で、最後の項目が item を落としているのは意図的です。そこは Google がページ自身の URL を使います。2つ目の型を出すために変わらなかったものにも注目してください。レイアウトも、Props の型も、レンダリングの行もそのままです。@graph を1つに絞っておいた実利がこれです。型ごとに <script> を置く書き方なら、型を足すたびにレイアウトまで触ることになります(トップレベルに別々のオブジェクトの配列を置くのも valid な JSON-LD なので、@graph を使わない選択もできます。その場合は Props の型が Record<string, unknown> | Record<string, unknown>[] に広がります)。

ルールはひとつだけ。経路は実在するナビゲーションを映していなければなりません。 Home → Articles → 記事 はこのサイトのヘッダーのリンクと URL 構造に一致しているので、このマークアップは実在するものを説明しています。目に見えるパンくずコンポーネントを置くのが自分を正直に保つ最も確実な方法で、作る価値もありますが、要件そのものは階層が実在することだけです。サイトに無い階層をでっち上げるのは近道ではなく、スパムのシグナルです。

検証はツール2つで

性格の違うツールが2つあり、両方使ってください。

  1. リッチリザルト テスト は、そのページが Google の機能の条件を満たすかを見ます。ここまでの話を確かめる最短の方法でもあります。FAQPage のマークアップが入ったページを食わせて、何も報告されないのを見てください。
  2. スキーマ マークアップ検証ツール は、Google と無関係に、その JSON-LD がschema.org として valid かを見ます。Google が消費しない型や、Google のツールが黙って無視するプロパティ名のタイポを捕まえるのに使います。

そして数週間おいてから、Search Console の拡張を確認します(商品スニペットと販売者向けリスティングはショッピングの下です)。先に言っておくと、レポートに出るのはリッチリザルトが存在する型だけです。パンくずは出ますが、BlogPosting は出ません。報告すべき Article のリッチリザルトのカードが無いからです。この不在こそ、冒頭の話のいちばん明快な裏付けになります。BlogPosting は配管であって、リッチリザルトではありません。

もうひとつ。新しいドメインでは、Google がページをクロールするまで何も存在しません。構造化データがインデックスを早めることはありません。効いてくるのはインデックスされた後です(まだ公開前なら、Cloudflare Workers へのデプロイ手順にサイトの公開と本番 URL の設定を書いています。上に出てきた絶対 URL はすべてそこが前提です)。

まとめ

  • HowToFAQPageSearchAction は書かない。2026年時点で3つとも死んでいます。すでに入っているものは放っておいて構いませんが、新しく書く必要はありません
  • BreadcrumbList が多くのサイトにとって最も価値の高い型です。経路は正直に保つこと。サイトに実在するナビゲーションと一致している必要があります
  • BlogPosting でカードは出ません。著者と日付を正しく読ませるためのものです。urlmainEntityOfPage は canonical と同一にし、image は絶対 URL にします
  • WebSite はサイト名を主張する手段です。古いチュートリアルの SearchAction は捨て、この半分だけ残します
  • Person / Organization / WebSite は安定した @id を付けて一度だけ定義し、記事ノードから参照します。こうすれば各ページはエンティティのフィールドを繰り返さず、@graph を1つ出すだけで済みます
  • オブジェクトは content collections のスキーマから組み立てて情報源をひとつにし、レイアウトへ Props で渡して、set:html で出力します(< のエスケープを忘れずに)

記事の代わりにリスティングが並ぶサイト、つまりディレクトリやカタログを作っているなら、同じパターンが ItemListProduct に伸びます。こちらは今も目に見える結果を出す型です。変わるのはデータ源だけで、形は同じ。ブログなら frontmatter、ディレクトリならデータベースです。どちらになるかはテーマ選びの段階で決まる話で、Astro のディレクトリテーマ比較に別途まとめました。

いずれにせよ原則は同じです。まだ効く型を出し、すでに保守しているデータから生成し、Google が引退させた型は引退させたままにしておくこと。