Google 検索結果に FAQ やパンくずリストが表示されているのを見たことがあるだろうか。あれが「リッチリザルト」で、構造化データ(JSON-LD)を正しく埋め込むことで表示される。
リッチリザルトが出ると、検索結果の占有面積が増えてクリック率が上がる。とくに個人開発のサイトは知名度で大手に勝てないので、検索結果の見た目で差をつけるのは有効な戦略になる。
この記事では、Next.js App Router で JSON-LD を自動生成する実装パターンを、69 ツールを載せている Web ツール集「ぱんだツールズ」の実コードをベースに解説する。
構造化データ(JSON-LD)とは
構造化データとは、ページの内容を検索エンジンが理解しやすい形で記述するためのメタデータのこと。フォーマットはいくつかあるが、Google が推奨しているのは JSON-LD(JavaScript Object Notation for Linked Data)だ。
HTML の <script type="application/ld+json"> タグに JSON を埋め込む形式で、ページの見た目に影響を与えずに構造化データを追加できる。
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "このツールは無料で使えますか?",
"acceptedAnswer": {
"@type": "Answer",
"text": "はい、完全無料でご利用いただけます。"
}
}
]
}
schema.org の主要タイプ
JSON-LD で使うスキーマは schema.org で定義されている。Web サービスやツールサイトでよく使うのは以下のタイプだ。
| タイプ | 用途 | リッチリザルト |
|---|---|---|
FAQPage |
よくある質問 | FAQ ドロップダウン |
BreadcrumbList |
パンくずリスト | パンくず表示 |
WebApplication |
Web アプリ | アプリ情報 |
HowTo |
手順説明 | ステップ表示 |
Article |
記事・ブログ | 記事カード |
この記事では、実際にぱんだツールズで使っている FAQPage と BreadcrumbList の実装を中心に解説する。
Next.js App Router での JSON-LD 実装方針
Next.js App Router で JSON-LD を埋め込む方法はシンプルで、React コンポーネントの中で <script type="application/ld+json"> を返すだけだ。
ポイントは 共通レイアウトコンポーネントに JSON-LD 生成ロジックを集約する こと。ぱんだツールズでは 69 個のツールページがあるが、JSON-LD のコードを各ページに書いているわけではない。共通の ToolPageLayout コンポーネントに集約して、props を渡すだけで FAQPage と BreadcrumbList の JSON-LD が自動生成される仕組みにしている。
src/
components/
ui/
ToolPageLayout.tsx ← JSON-LD 生成ロジックを集約
app/
tools/
[tool-slug]/
page.tsx ← Metadata API でメタ情報を定義
XxxClient.tsx ← ToolPageLayout に props を渡す
Metadata API と JSON-LD の役割分担
Next.js App Router の Metadata API(export const metadata や generateMetadata)は <title> や <meta> タグの生成を担当する。一方、JSON-LD は <script> タグとして埋め込むので、Metadata API とは別にコンポーネント内で生成する。
// app/tools/pdf-compress/page.tsx — Metadata API
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'PDF圧縮ツール(無料・ブラウザで簡単)',
description: 'PDFファイルのサイズをブラウザ内で圧縮。',
alternates: {
canonical: '/tools/pdf-compress',
},
openGraph: {
title: 'PDF圧縮ツール(無料・ブラウザで簡単) | ぱんだツールズ',
description: 'PDFファイルのサイズをブラウザ内で圧縮。',
url: 'https://sakutto-panda.com/tools/pdf-compress',
siteName: 'ぱんだツールズ',
locale: 'ja_JP',
type: 'website',
},
}
Metadata API で title / description / canonical / openGraph を定義し、JSON-LD は共通レイアウト側で生成する。この分離が重要で、ページごとに JSON-LD を手書きする必要がなくなる。
実装例: FAQPage スキーマの自動生成
ぱんだツールズでは、各ツールページに FAQ セクションがあり、そのデータをそのまま JSON-LD に変換している。
共通レイアウトコンポーネント
// components/ui/ToolPageLayout.tsx
export interface FaqItem {
q: string
a: string
}
interface ToolPageLayoutProps {
title: string
description: string
faqItems: FaqItem[]
children: React.ReactNode
currentSlug: string
// icon, iconBg, crossLink なども実際にはあるが、JSON-LD に関連するプロパティのみ抜粋
}
export default function ToolPageLayout({
title,
description,
faqItems,
children,
currentSlug,
}: ToolPageLayoutProps) {
// FAQ データから JSON-LD を自動生成
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* ページ本体 */}
<h1>{title}</h1>
<p>{description}</p>
{children}
{/* FAQ セクション */}
<section>
<h2>よくある質問</h2>
{faqItems.map((item) => (
<div key={item.q}>
<p>{item.q}</p>
<p>{item.a}</p>
</div>
))}
</section>
</>
)
}
各ツールページからの利用
// app/tools/pdf-compress/PdfCompressClient.tsx
'use client'
import ToolPageLayout from '@/components/ui/ToolPageLayout'
const faqItems = [
{
q: 'PDF圧縮ツールは無料で使えますか?',
a: 'はい、完全無料でご利用いただけます。登録・インストール不要でブラウザからすぐに使えます。',
},
{
q: 'ファイルはサーバーに送信されますか?',
a: 'いいえ、すべての処理はお使いのブラウザ内で完結します。',
},
{
q: '対応しているPDFファイルサイズは?',
a: '最大20MBまでのPDFファイルに対応しています。',
},
]
export default function PdfCompressClient() {
return (
<ToolPageLayout
title="PDF圧縮"
description="PDFファイルのサイズをブラウザ内で圧縮"
faqItems={faqItems}
currentSlug="pdf-compress"
>
{/* ツール本体の UI */}
</ToolPageLayout>
)
}
このパターンのメリットは明確で、FAQ の表示と JSON-LD が必ず同期する。FAQ データを 1 箇所で定義して、表示にも JSON-LD にも使い回しているので、「画面には表示されているけど JSON-LD に含まれていない」といった不整合が起きない。
XSS 対策: < のエスケープ
JSON-LD を dangerouslySetInnerHTML で埋め込む際、< を \\u003c に置換している点に注目してほしい。
JSON.stringify(jsonLd).replace(/</g, '\\u003c')
これは FAQ の回答テキストに <script> のような文字列が含まれていた場合に、ブラウザが </script> として解釈してしまうのを防ぐための対策だ。JSON-LD を dangerouslySetInnerHTML で埋め込む場合は必ずやっておくべきエスケープ処理になる。
実装例: BreadcrumbList スキーマの動的生成
パンくずリスト(BreadcrumbList)は、サイト内の階層構造を検索エンジンに伝えるための構造化データだ。Google 検索結果に「ホーム > PDFツール > PDF圧縮」のようなパスが表示される。
ぱんだツールズでは、ツールが属するカテゴリ情報をもとに、BreadcrumbList の JSON-LD を動的に生成している。
// components/ui/ToolPageLayout.tsx の一部
// ツールのカテゴリを検索
const currentCategory = toolCategories.find((cat) =>
cat.items.some((item) => item.slug === currentSlug)
)
// カテゴリ名からスラッグを取得(例: "PDF" → "pdf")
const categorySlug = getCategorySlug(currentCategory?.category)
// カテゴリが見つかった場合のみ BreadcrumbList を出力
{currentCategory && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'ホーム',
item: 'https://sakutto-panda.com',
},
{
'@type': 'ListItem',
position: 2,
name: `${currentCategory.category}ツール`,
item: `https://sakutto-panda.com/tools/${categorySlug}`,
},
{
'@type': 'ListItem',
position: 3,
name: title,
item: `https://sakutto-panda.com/tools/${currentSlug}`,
},
],
}).replace(/</g, '\\u003c'),
}}
/>
)}
動的パンくず生成のポイント
この実装で重要なのは以下の 3 点だ。
1. カテゴリ情報を一元管理されたデータから取得する
ツール定義データ(src/data/tools.tsx)にカテゴリとツールの紐付けが定義されているので、currentSlug からカテゴリを自動で逆引きできる。新しいツールを追加するときにパンくず用の設定を別途書く必要がない。
2. 各 ListItem に item(URL)を必ず含める
Google のガイドラインでは、最後のアイテム以外の ListItem に item プロパティ(URL)を含めることが推奨されている。ぱんだツールズでは最後のアイテムにも URL を含めている。
3. position は 1 始まりの連番にする
position の値は 1 から始まる連番でなければならない。ここを 0 始まりにしたり飛ばしたりすると、Google に正しく認識されない。
表示用パンくずとの同期
JSON-LD だけでなく、実際に画面に表示するパンくずナビゲーションも同じデータから生成している。
{currentCategory && (
<nav aria-label="パンくずリスト">
<Link href="/">ホーム</Link>
<span>›</span>
<Link href={`/tools/${categorySlug}`}>
{currentCategory.category}ツール
</Link>
<span>›</span>
<span>{title}</span>
</nav>
)}
FAQ のときと同じ原則で、表示用のパンくずと JSON-LD のパンくずが同じデータソースから生成されるため、不整合が起きない設計になっている。
サイトマップとの連携
構造化データと合わせて、サイトマップも適切に設定しておくと SEO 効果が高まる。Next.js App Router では app/sitemap.ts を置くだけで /sitemap.xml が自動生成される。
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { tools } from '@/data/tools'
export const dynamic = 'force-static'
const BASE_URL = 'https://sakutto-panda.com'
const LAST_UPDATED = new Date('2026-04-11')
export default function sitemap(): MetadataRoute.Sitemap {
// 全ツールページを自動生成
const toolPages = tools.map((tool) => ({
url: `${BASE_URL}/tools/${tool.slug}`,
lastModified: LAST_UPDATED,
changeFrequency: 'monthly' as const,
priority: 0.8,
}))
// カテゴリページ
const categoryPages = ['pdf', 'image', 'csv', 'text', 'file', 'dev'].map(
(slug) => ({
url: `${BASE_URL}/tools/${slug}`,
lastModified: LAST_UPDATED,
changeFrequency: 'weekly' as const,
priority: 0.7,
})
)
return [
{
url: BASE_URL,
lastModified: LAST_UPDATED,
changeFrequency: 'weekly',
priority: 1.0,
},
...categoryPages,
...toolPages,
]
}
priority と changeFrequency の戦略
priority と changeFrequency は Google がどの程度参考にしているか公式には不明だが、設定しておいて損はない。ぱんだツールズでは以下のような方針にしている。
| ページ | priority | changeFrequency |
|---|---|---|
| トップページ | 1.0 | weekly |
| カテゴリページ | 0.7 | weekly |
| 各ツールページ | 0.8 | monthly |
ツールページの priority をカテゴリページより高くしているのは、実際にユーザーが検索から辿り着くのはツールページだからだ。「PDF 圧縮 無料」で検索する人は、カテゴリ一覧ではなくツール本体のページを求めている。
export const dynamic = 'force-static' を指定しているのも重要で、これによりビルド時にサイトマップが静的生成される。Cloudflare Pages のような静的ホスティングでもサイトマップが正しく配信される。
Google Search Console での検証方法
実装が終わったら、Google Search Console で構造化データが正しく認識されているか確認する。
リッチリザルトテスト
- Google の「リッチリザルトテスト」にアクセス
- 検証したいページの URL を入力
- 「URL をテスト」をクリック
- 検出されたスキーマタイプとエラー・警告を確認
FAQPage が正しく実装されていれば「FAQPage - 有効」、BreadcrumbList は「パンくずリスト - 有効」と表示される。
Search Console の「拡張」レポート
Google Search Console の左メニュー「拡張」セクションに、構造化データの検出状況がまとめて表示される。
- よくある質問(FAQ): FAQPage スキーマの検出数とエラー
- パンくずリスト: BreadcrumbList スキーマの検出数とエラー
エラーがある場合は、該当ページの URL とエラー内容が表示されるので、1 つずつ修正していく。
よくあるエラーと対策
| エラー | 原因 | 対策 |
|---|---|---|
name が空 |
FAQ の質問テキストが空文字 | faqItems のバリデーションを追加 |
item が不正な URL |
BreadcrumbList の URL が相対パス | 絶対 URL を指定する |
| 構造化データが検出されない | JSON-LD の構文エラー |
JSON.stringify で生成すれば構文エラーは起きない |
JSON-LD を JSON.stringify で生成している場合、手書きの JSON にありがちな構文エラー(カンマの付け忘れ、引用符の不一致など)は発生しない。これも共通コンポーネントで自動生成するメリットの 1 つだ。
まとめ
Next.js App Router で構造化データ(JSON-LD)を実装するポイントを整理する。
- JSON-LD は
<script type="application/ld+json">で埋め込む。Metadata API とは別にコンポーネントで生成する - 共通レイアウトに集約 して、各ページでは props を渡すだけにする。69 ページ分の JSON-LD を個別に書く必要はない
- FAQ の表示データと JSON-LD を同じデータソースから生成することで、不整合を構造的に防ぐ
-
dangerouslySetInnerHTMLで埋め込む際は<を\\u003cにエスケープする - BreadcrumbList はカテゴリ情報から動的に生成する。新ツール追加時に手動設定が不要
- サイトマップもツール定義データから自動生成して、メンテナンスコストを最小化する
- Google Search Console のリッチリザルトテストで検証を忘れずに
構造化データの実装自体は難しくない。大事なのは「全ページで漏れなく・正しく・メンテナンスコストをかけずに」運用できる仕組みにすることだ。共通コンポーネントへの集約と、一元管理されたデータからの自動生成がその答えになる。
ぱんだツールズ — PDF圧縮・画像変換・CSV処理など 69 個のツールがブラウザで完結。登録不要・完全無料。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。