WordPress Structured Data (JSON-LD)
Model note: Building a single schema type is mechanical (haiku). Reach for sonnet/opus only when reconciling a complex graph against an existing SEO-plugin graph.
Emit schema.org JSON-LD from theme/plugin code for content an SEO plugin can't generate on its own — without duplicating what the SEO plugin already ships.
When to use
- Content lives in post meta / custom fields (e.g. an FAQ repeater) and is stripped from the rendered content, so Rank Math/Yoast can't detect it.
- You render a custom section (table of contents, related items, steps) that warrants
ItemList / HowTo.
- You need a schema type the SEO plugin doesn't offer on that template.
Rule 0 — never duplicate the SEO plugin's graph
Most sites already run Rank Math, Yoast, or SEOPress. They emit a @graph with WebSite, WebPage, Organization, Person, BreadcrumbList, and an article type (Article/BlogPosting/Product). Never re-emit those — duplicate/competing nodes confuse parsers and can suppress rich results.
Always check first what is already on the page:
# View source, then list the @type values in every ld+json block
curl -s "<url>" | grep -A99 'application/ld+json'
Or in the browser console:
[...document.querySelectorAll('script[type="application/ld+json"]')]
.flatMap(s => { const j = JSON.parse(s.textContent); return (j['@graph']||[j]).map(n => n['@type']); });
Only add types the existing graph is missing (commonly FAQPage, HowTo, Recipe, custom ItemList).
Pattern — build in PHP, print on wp_head
add_action( 'wp_head', function () {
if ( ! is_singular( 'post' ) ) {
return;
}
$faqs = get_post_meta( get_the_ID(), 'my_faqs', true ); // stripped from content
if ( empty( $faqs ) || ! is_array( $faqs ) ) {
return;
}
$questions = array();
foreach ( $faqs as $faq ) {
if ( empty( $faq['question'] ) ) {
continue;
}
$questions[] = array(
'@type' => 'Question',
'name' => wp_strip_all_tags( $faq['question'] ),
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => wp_kses_post( wpautop( $faq['answer'] ?? '' ) ),
),
);
}
if ( ! $questions ) {
return;
}
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $questions,
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n";
} );
Key points
- Build a PHP array, then
wp_json_encode() — never hand-concatenate JSON. wp_json_encode() escapes for the <script> context; output is safe to print as-is. (PHPCS's EscapeOutput sniff doesn't recognise it as an escaper; suppress it via a centralized phpcs.xml.dist exclude scoped to the file rather than scattering inline ignores.)
@type text: wp_strip_all_tags() for short names/titles; wp_kses_post() for answer/description bodies that may carry HTML.
- head vs footer is irrelevant to Google — it parses JSON-LD anywhere. Use
wp_head to sit alongside the SEO plugin's graph (convention), or wp_footer if you need late data; both work.
- Gate to the right context (
is_singular(), post type, a feature toggle) so schema only appears where the content does.
- Mirror visible content. Schema must reflect what users actually see on the page (Google's structured-data policy). Don't emit FAQ schema for FAQs you don't render.
Common types worth emitting from code
| Type |
Use for |
Usually missing from SEO plugins? |
FAQPage |
Q&A stored in meta / repeater |
Yes, when stripped from content |
HowTo |
Step-by-step tutorial sections |
Often |
ItemList |
TOC, related items, rankings |
Yes |
Recipe |
Recipe meta fields |
Yes (unless a recipe plugin) |
Article/BlogPosting |
The post itself |
No — SEO plugin owns this |
BreadcrumbList |
Breadcrumb trail |
No — SEO plugin owns this |
Validate
- Google Rich Results Test (search.google.com/test/rich-results) — confirms eligibility per type.
- Schema.org validator (validator.schema.org) — structural correctness.
- Confirm there's exactly one node per type across all blocks (no duplicate
Article/FAQPage).
Common Mistakes
| Mistake |
Fix |
Re-emitting Article/BreadcrumbList/Organization the SEO plugin already outputs |
Never duplicate — inspect existing ld+json first; add only missing types |
| Hand-building the JSON string |
Always build a PHP array + wp_json_encode() |
| FAQ schema present but FAQs not visible on the page |
Mirror visible content — only emit schema for rendered content |
| Assuming head vs footer affects indexing |
It doesn't; place by convention (wp_head) |
Inline // phpcs:ignore for the wp_json_encode echo |
Centralize the EscapeOutput exclude in phpcs.xml.dist, scoped to the file |
| Emitting schema on every template |
Gate with is_singular() / post type / feature toggle |
1---2name: wp-structured-data3description: Use when emitting JSON-LD structured data (schema.org) from a WordPress theme or plugin — FAQPage, HowTo, ItemList, Recipe, Event, Product, Review — especially when the data lives in post meta / custom fields the SEO plugin can't see, OR when you must avoid duplicating the schema an SEO plugin (Rank Math, Yoast SEO, SEOPress) already outputs. Covers building the @graph in PHP, escaping with wp_json_encode, printing on wp_head, validating, and coexisting with SEO-plugin graphs. Triggers: "add FAQ schema", "FAQPage JSON-LD", "add structured data", "schema.org markup WordPress", "rich results", "HowTo schema", "ItemList schema", "my FAQ rich result isn't showing", "duplicate Article schema", "Rank Math already outputs schema", "Yoast schema graph", "json-ld in head or footer", "wp_json_encode schema", "schema for custom fields", "breadcrumb schema duplicate", "validate structured data". Not for: configuring an SEO plugin's built-in schema UI (use the plugin's own settings); meta tags / OpenGraph (that's SEO-plug4---56# WordPress Structured Data (JSON-LD)78> **Model note:** Building a single schema type is mechanical (`haiku`). Reach for `sonnet`/`opus` only when reconciling a complex graph against an existing SEO-plugin graph.910Emit schema.org JSON-LD from theme/plugin code for content an SEO plugin can't generate on its own — without duplicating what the SEO plugin already ships.1112## When to use1314- Content lives in **post meta / custom fields** (e.g. an FAQ repeater) and is *stripped from the rendered content*, so Rank Math/Yoast can't detect it.15- You render a **custom section** (table of contents, related items, steps) that warrants `ItemList` / `HowTo`.16- You need a schema type the SEO plugin doesn't offer on that template.1718## Rule 0 — never duplicate the SEO plugin's graph1920Most sites already run Rank Math, Yoast, or SEOPress. They emit a `@graph` with `WebSite`, `WebPage`, `Organization`, `Person`, `BreadcrumbList`, and an article type (`Article`/`BlogPosting`/`Product`). **Never** re-emit those — duplicate/competing nodes confuse parsers and can suppress rich results.2122**Always** check first what is already on the page:2324```bash25# View source, then list the @type values in every ld+json block26curl -s "<url>" | grep -A99 'application/ld+json'27```2829Or in the browser console:3031```js32[...document.querySelectorAll('script[type="application/ld+json"]')]33 .flatMap(s => { const j = JSON.parse(s.textContent); return (j['@graph']||[j]).map(n => n['@type']); });34```3536Only add types the existing graph is **missing** (commonly `FAQPage`, `HowTo`, `Recipe`, custom `ItemList`).3738## Pattern — build in PHP, print on `wp_head`3940```php41add_action( 'wp_head', function () {42 if ( ! is_singular( 'post' ) ) {43 return;44 }4546 $faqs = get_post_meta( get_the_ID(), 'my_faqs', true ); // stripped from content47 if ( empty( $faqs ) || ! is_array( $faqs ) ) {48 return;49 }5051 $questions = array();52 foreach ( $faqs as $faq ) {53 if ( empty( $faq['question'] ) ) {54 continue;55 }56 $questions[] = array(57 '@type' => 'Question',58 'name' => wp_strip_all_tags( $faq['question'] ),59 'acceptedAnswer' => array(60 '@type' => 'Answer',61 'text' => wp_kses_post( wpautop( $faq['answer'] ?? '' ) ),62 ),63 );64 }6566 if ( ! $questions ) {67 return;68 }6970 $schema = array(71 '@context' => 'https://schema.org',72 '@type' => 'FAQPage',73 'mainEntity' => $questions,74 );7576 echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n";77} );78```7980### Key points8182- **Build a PHP array, then `wp_json_encode()`** — never hand-concatenate JSON. `wp_json_encode()` escapes for the `<script>` context; output is safe to print as-is. (PHPCS's `EscapeOutput` sniff doesn't recognise it as an escaper; suppress it via a centralized `phpcs.xml.dist` exclude scoped to the file rather than scattering inline ignores.)83- **`@type` text:** `wp_strip_all_tags()` for short names/titles; `wp_kses_post()` for answer/description bodies that may carry HTML.84- **head vs footer is irrelevant to Google** — it parses JSON-LD anywhere. Use `wp_head` to sit alongside the SEO plugin's graph (convention), or `wp_footer` if you need late data; both work.85- **Gate to the right context** (`is_singular()`, post type, a feature toggle) so schema only appears where the content does.86- **Mirror visible content.** Schema must reflect what users actually see on the page (Google's structured-data policy). Don't emit FAQ schema for FAQs you don't render.8788## Common types worth emitting from code8990| Type | Use for | Usually missing from SEO plugins? |91|------|---------|-----------------------------------|92| `FAQPage` | Q&A stored in meta / repeater | Yes, when stripped from content |93| `HowTo` | Step-by-step tutorial sections | Often |94| `ItemList` | TOC, related items, rankings | Yes |95| `Recipe` | Recipe meta fields | Yes (unless a recipe plugin) |96| `Article`/`BlogPosting` | The post itself | **No — SEO plugin owns this** |97| `BreadcrumbList` | Breadcrumb trail | **No — SEO plugin owns this** |9899## Validate100101- Google **Rich Results Test** (search.google.com/test/rich-results) — confirms eligibility per type.102- Schema.org **validator** (validator.schema.org) — structural correctness.103- Confirm there's exactly **one** node per type across all blocks (no duplicate `Article`/`FAQPage`).104105## Common Mistakes106107| Mistake | Fix |108|---------|-----|109| Re-emitting `Article`/`BreadcrumbList`/`Organization` the SEO plugin already outputs | **Never** duplicate — inspect existing `ld+json` first; add only missing types |110| Hand-building the JSON string | **Always** build a PHP array + `wp_json_encode()` |111| FAQ schema present but FAQs not visible on the page | Mirror visible content — only emit schema for rendered content |112| Assuming head vs footer affects indexing | It doesn't; place by convention (`wp_head`) |113| Inline `// phpcs:ignore` for the `wp_json_encode` echo | Centralize the `EscapeOutput` exclude in `phpcs.xml.dist`, scoped to the file |114| Emitting schema on every template | Gate with `is_singular()` / post type / feature toggle |