2026年8月8日
2026年8月8日
WordPressのパンくずリストを1つのデータで管理し、HTMLとJSON-LDに出し分ける方法
パンくずリストを自作するなら、まず「ホーム › カテゴリー › 記事」という階層を1つの配列(データ)として組み立て、そこから画面用のHTMLと構造化データ(JSON-LDのBreadcrumbList)の両方を出力する作りにすると、2つの内容が食い違うことがなくなります。この記事では、その配列を作る関数、HTMLとJSON-LDの出力、フィルターでの調整、ヘッドレス向けにREST APIで返す方法、SEOプラグインとの重複の避け方、確認方法までを順に解説します。
パンくずの仕組みそのものの選び方(プラグインかテーマ機能か)は別記事で比較しています。この記事は「自分のコードでデータとして管理したい人」向けです。
なぜ「データ」として管理するのか
パンくずの実装でよくある失敗は、テーマのテンプレートでHTMLを直接組み立て、構造化データは別の場所(プラグインや別の関数)で作っている状態です。この形だと、たとえばカテゴリーの構成を変えたときに片方だけが更新され、画面のパンくずと検索エンジン向けのデータが一致しなくなります。
そこで、次のように役割を分けます。
- データを作る関数: 現在のページに応じて、
name(表示名)とurlの組の配列を返す - HTMLを出す関数: 配列を受け取り、
<nav>と<ol>のリストにする - JSON-LDを出す処理: 同じ配列から
BreadcrumbListを作る - REST API: ヘッドレス構成などで必要なら、同じ配列をJSONとして返す
調整したいことがあれば、データを作る段階でフィルターを1か所に掛けるだけで、すべての出力に反映されます。
始める前に:既存の出力を確認する
最初に、すでにパンくずの構造化データが出ていないかを確認します。Yoast SEOやRank MathなどのSEOプラグインは、設定によって独自のパンくずやBreadcrumbListのデータを出力します。テーマが出力している場合もあります。
確認は、記事ページのHTMLソースで BreadcrumbList を検索するか、Googleのリッチリザルトテストに記事のURLを入れて行います。
すでに出ている場合の選択肢は2つです。
- プラグインのパンくずを使い続ける: 自作はせず、プラグイン側のフィルターで項目を調整します。Yoast SEOには
wpseo_breadcrumb_linksフィルターがあり、Rank Mathにもパンくずの項目を変更するフィルターが用意されています。名前や引数はバージョンで変わることがあるため、各プラグインの開発者向けドキュメントで確認してください。 - 自作に切り替える: プラグインのパンくず機能を設定で無効にしてから、この記事のコードを入れます。内容の異なるBreadcrumbListが2つ出る状態は避けます。プラグインによっては、パンくず機能を切っても構造化データの一部が残る場合があるので、切り替え後にもう一度ソースを確認します。
コードは子テーマの functions.php(またはサイト専用プラグイン)に追加します。作業前にバックアップを取り、ステージング環境で確認してから本番に反映してください。
パンくずのデータを作る関数
記事・固定ページ用の関数と、現在のページ全般を扱う関数の2つに分けます。記事用を独立させておくと、REST APIからも同じ処理を使えます。
<?php
// 表示名をプレーンテキストにそろえる(– などの文字参照を元の文字に戻す)
function wpdr_bc_text( $text ) {
return html_entity_decode( wp_strip_all_tags( $text ), ENT_QUOTES | ENT_HTML5, get_bloginfo( 'charset' ) );
}
function wpdr_bc_term_item( $term ) {
$link = get_term_link( $term );
if ( is_wp_error( $link ) ) {
return null;
}
return array( 'name' => wpdr_bc_text( $term->name ), 'url' => $link );
}
// 階層のあるタクソノミーで、親から順にタームを並べる
function wpdr_bc_term_chain( $term ) {
$items = array();
$ids = array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) );
$ids[] = $term->term_id;
foreach ( $ids as $id ) {
$item = wpdr_bc_term_item( get_term( $id, $term->taxonomy ) );
if ( $item ) {
$items[] = $item;
}
}
return $items;
}
function wpdr_bc_home() {
return array( 'name' => 'ホーム', 'url' => home_url( '/' ) );
}
// 記事・固定ページ・カスタム投稿タイプ1件分のパンくず
function wpdr_breadcrumb_items_for_post( $post ) {
$post = get_post( $post );
$items = array( wpdr_bc_home() );
if ( ! $post ) {
return $items;
}
if ( 'page' === $post->post_type ) {
foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor_id ) {
$items[] = array( 'name' => wpdr_bc_text( get_the_title( $ancestor_id ) ), 'url' => get_permalink( $ancestor_id ) );
}
} elseif ( 'post' === $post->post_type ) {
// 複数のカテゴリーに属する場合は、階層が最も深いものを選ぶ
$best = null;
foreach ( get_the_category( $post->ID ) as $cat ) {
if ( ! $best || count( get_ancestors( $cat->term_id, 'category', 'taxonomy' ) ) > count( get_ancestors( $best->term_id, 'category', 'taxonomy' ) ) ) {
$best = $cat;
}
}
if ( $best ) {
$items = array_merge( $items, wpdr_bc_term_chain( $best ) );
}
} else {
$pto = get_post_type_object( $post->post_type );
$archive = get_post_type_archive_link( $post->post_type );
if ( $pto && $archive ) {
$items[] = array( 'name' => wpdr_bc_text( $pto->labels->name ), 'url' => $archive );
}
}
$items[] = array( 'name' => wpdr_bc_text( get_the_title( $post ) ), 'url' => get_permalink( $post ) );
return apply_filters( 'wpdr_breadcrumb_items', $items, $post );
}
// 現在表示中のページのパンくず(1回のリクエスト内では結果を使い回す)
function wpdr_breadcrumb_items() {
static $cache = null;
if ( null !== $cache ) {
return $cache;
}
if ( is_front_page() ) {
return $cache = array();
}
if ( is_singular() ) {
return $cache = wpdr_breadcrumb_items_for_post( get_queried_object() );
}
$items = array( wpdr_bc_home() );
$obj = get_queried_object();
if ( ( is_category() || is_tag() || is_tax() ) && $obj instanceof WP_Term ) {
if ( is_taxonomy_hierarchical( $obj->taxonomy ) ) {
$items = array_merge( $items, wpdr_bc_term_chain( $obj ) );
} elseif ( $item = wpdr_bc_term_item( $obj ) ) {
$items[] = $item;
}
} elseif ( is_post_type_archive() && $obj instanceof WP_Post_Type ) {
$items[] = array( 'name' => wpdr_bc_text( $obj->labels->name ), 'url' => get_post_type_archive_link( $obj->name ) );
} elseif ( is_home() && get_option( 'page_for_posts' ) ) {
$page_id = (int) get_option( 'page_for_posts' );
$items[] = array( 'name' => wpdr_bc_text( get_the_title( $page_id ) ), 'url' => get_permalink( $page_id ) );
} elseif ( is_search() ) {
$items[] = array( 'name' => sprintf( '「%s」の検索結果', get_search_query( false ) ), 'url' => get_search_link() );
} elseif ( is_404() ) {
$items[] = array( 'name' => 'ページが見つかりません', 'url' => '' );
}
return $cache = apply_filters( 'wpdr_breadcrumb_items', $items, null );
}
作るときの注意点
表示名はプレーンテキストにする: get_the_title() は、引用符やダッシュを ’ のような文字参照に変換して返します。そのままJSON-LDに入れると、文字参照が文字列として残ってしまいます。配列には元の文字に戻した値を入れておき、HTMLに出すときに esc_html でエスケープします。「保存時はプレーン、出力時にエスケープ」と決めておくと、二重エスケープも防げます。
カテゴリーの選び方を決めておく: 記事が複数のカテゴリーに属する場合、どれをパンくずに使うかで結果が変わります。上のコードでは階層が最も深いカテゴリーを選んでいますが、サイトの方針に合わせて変えて構いません。SEOプラグインの「メインカテゴリー」の設定を使っている場合は、その値を読む形にすると管理画面と一致させやすくなります。
HTMLとJSON-LDを出力する
同じ配列から、画面用のHTMLとJSON-LDを作ります。
// 画面用のHTML(ショートコード [wpdr_breadcrumb] でも出せるようにする)
function wpdr_breadcrumb_html() {
$items = wpdr_breadcrumb_items();
if ( count( $items ) < 2 ) {
return '';
}
$last = count( $items ) - 1;
$html = '<nav class="wpdr-breadcrumb" aria-label="パンくずリスト"><ol>';
foreach ( $items as $i => $item ) {
if ( $i === $last ) {
$html .= '<li><span aria-current="page">' . esc_html( $item['name'] ) . '</span></li>';
} elseif ( '' !== $item['url'] ) {
$html .= '<li><a href="' . esc_url( $item['url'] ) . '">' . esc_html( $item['name'] ) . '</a></li>';
} else {
$html .= '<li>' . esc_html( $item['name'] ) . '</li>';
}
}
return $html . '</ol></nav>';
}
add_shortcode( 'wpdr_breadcrumb', 'wpdr_breadcrumb_html' );
// 構造化データ(BreadcrumbList)
add_action( 'wp_head', function () {
if ( is_search() || is_404() ) {
return; // 検索結果や404ページには出さない
}
$items = array_values( array_filter( wpdr_breadcrumb_items(), function ( $item ) {
return '' !== $item['url'];
} ) );
if ( count( $items ) < 2 ) {
return;
}
$list = array();
foreach ( $items as $i => $item ) {
$list[] = array(
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => $item['url'],
);
}
$data = array(
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $list,
);
wp_print_inline_script_tag(
wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ),
array( 'type' => 'application/ld+json' )
);
} );
HTMLは、<nav> に aria-label を付けてパンくずであることを伝え、順序のあるリスト(<ol>)で並べ、現在のページに aria-current="page" を付けています。スクリーンリーダーの利用者にも「どこにいるか」が伝わる書き方です。
クラシックテーマならテンプレートの表示したい位置で echo wpdr_breadcrumb_html(); と書き、ブロックテーマならサイトエディターでテンプレートに「ショートコード」ブロックを置いて [wpdr_breadcrumb] と入力します。
JSON-LDの出力で JSON_HEX_TAG を付けているのは安全のためです。記事タイトルなどに </script> という文字列が含まれていても、< と > が別の表記に置き換わるので、scriptタグが途中で閉じられることがありません。wp_print_inline_script_tag はWordPress 5.7で追加された関数で、属性付きのscriptタグを正しく出力できます。
フィルターで階層を調整する
データを作る段階でフィルター wpdr_breadcrumb_items を通しているので、例外的な階層もここで1か所に書けます。たとえば、カスタム投稿タイプ「news」の記事だけ、固定ページ「会社情報」(スラッグ about)の下に置く例です。
add_filter( 'wpdr_breadcrumb_items', function ( $items, $post ) {
if ( $post && 'news' === $post->post_type ) {
$about = get_page_by_path( 'about' );
if ( $about ) {
array_splice( $items, 1, 0, array( array(
'name' => wpdr_bc_text( get_the_title( $about ) ),
'url' => get_permalink( $about ),
) ) );
}
}
return $items;
}, 10, 2 );
この変更はHTML・JSON-LD・REST APIのすべてに反映されます。
REST APIでJSONとして返す(ヘッドレス向け)
フロントエンドをNext.jsなどで別に作っている場合は、記事データと一緒にパンくずの配列を返すと便利です。register_rest_field で、投稿と固定ページのREST APIに項目を追加します。
add_action( 'rest_api_init', function () {
register_rest_field( array( 'post', 'page' ), 'wpdr_breadcrumb', array(
'get_callback' => function ( $object ) {
return wpdr_breadcrumb_items_for_post( $object['id'] );
},
'schema' => array(
'description' => 'パンくずリストの項目',
'type' => 'array',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'name' => array( 'type' => 'string' ),
'url' => array( 'type' => 'string', 'format' => 'uri' ),
),
),
),
) );
} );
/wp-json/wp/v2/posts/123 の応答に wpdr_breadcrumb が含まれるようになります。フロントエンド側では、この配列からHTMLとJSON-LDを作れば、WordPress側と同じ階層を保てます。
確認方法と検索結果での扱い
実装したら、次の順で確認します。
- 表示の確認: 記事、固定ページ(子ページ)、カテゴリー一覧、カスタム投稿タイプのページで、パンくずの階層とリンク先が正しいかを見ます。
- 構造化データの確認: Googleのリッチリザルトテストで記事のURLを調べ、パンくずリストとして認識され、エラーや警告がないかを確認します。BreadcrumbListが2つ検出された場合は、SEOプラグインやテーマの出力が残っています。
- Search Consoleの確認: 公開後しばらくすると、Search Consoleの拡張(エンハンスメント)関連のレポートにパンくずリストの状況が表示されます。レポートの名前や場所は変わることがあります。
構造化データは、検索エンジンにページの位置づけを伝えるためのものであり、順位の上昇を約束するものではありません。また、検索結果にパンくずをどう表示するかはGoogleが決めます。2025年にはモバイルの検索結果でパンくずの表示を簡略化する変更も発表されています。表示の仕様は今後も変わる可能性があるため、Google検索セントラルの最新情報で確認してください(2026年10月現在)。パンくずは、まず読者がサイトの中で迷わないための導線として設計するのが基本です。
まとめ
- パンくずは
nameとurlの配列として1か所で作り、HTML・JSON-LD・REST APIのすべてをそこから出力すると、内容の食い違いを防げます。 - 始める前に、SEOプラグインやテーマがBreadcrumbListを出していないかを確認し、出力を1つにそろえます。
- 配列にはプレーンテキストを入れ、HTML出力時に
esc_html・esc_urlでエスケープします。JSON-LDはJSON_HEX_TAG付きで出します。 - 例外的な階層はフィルターで1か所に書き、すべての出力に反映させます。
- 確認はリッチリザルトテストとSearch Consoleで行い、検索結果の見た目や順位への効果は約束されないことを前提にします。