On a large site, structured data breaks when more than one tool writes it. The fix is one authoritative schema source, defined once in code, that every page type inherits from and extends with only its own small piece. On ampy.se, this took fifteen plus validation errors across a thousand plus pages down to zero. Rich results, stars, prices, and expandable FAQ entries appear in Google without any ongoing maintenance, because nobody hand edits schema anymore.
Look at a Google results page for anything you sell. The listings that pull the eye are no longer plain blue links: they carry star ratings, prices, and little expandable questions right there in the results. Those are rich results, and they quietly win the click. They take up more room, they signal trust before anyone lands on your site, and they cost you nothing for each visitor they send. On a large site, earning them across the board is worth real money, and it comes down to one thing you fully control.
The thing that makes those rich results appear is structured data: a small block of machine-readable information you put on each page telling Google exactly what the page is. Get it right and you earn free visibility. Get it wrong, and you get nothing, or worse, Google flags errors and quietly ignores the page.
Here is the trap. On a big site, structured data is usually a mess. Your SEO plugin adds some. Your theme adds some. A page builder adds some more. They contradict each other, they duplicate each other, and validation errors pile up. On one site I run, ampy.se, there were fifteen plus schema errors spread across a thousand plus pages. I fixed it by taking control: one authoritative source of schema, defined once, that every page type inherits from and extends. After that, zero errors, and they stay at zero because nobody hand edits schema ever again.
This is exactly how I did it, step by step, in a way you can copy.
Why one source, and why inheritance
Two ideas do all the work here. Understand them and the code is easy. The rest of this guide is just plumbing that carries these two ideas onto every page of a large site without you touching them again.
One authoritative source
Structured data breaks when more than one thing writes it. If your SEO plugin says the page is an Article and your theme says it is a WebPage with different details, Google sees a conflict. The fix is not to tune each source. The fix is to switch every other source OFF and let one piece of code you control emit all of it. One source, one voice, no contradictions.
Why plugins fight each other
Every plugin author wants their tool to work out of the box, so each one ships schema turned on by default. That is helpful on a five page brochure site. On a large site it is chaos, because three tools all claim the right to describe the same page and none of them knows the others exist. You cannot win that fight by configuring each plugin. You win it by ending the fight: one emitter, everyone else silent.
Inheritance instead of hand writing
The second idea is the one that keeps it valid forever. Most sites hand write schema per page or per template. That does not scale. Someone forgets a field, someone pastes the wrong ID, and drift creeps in as the site grows.
Instead, you define a base graph once. Every site shares the same Organization (who you are) and the same WebSite (the site itself). Those never change from page to page, so you write them a single time. Then each page type inherits that base and adds only its own small piece: a Product adds price and rating, an Article adds author and date, a location page adds its address, an FAQ adds its questions. Nobody rewrites the shared parts. That is why it cannot drift.
Define once, reference everywhere
The trick that makes inheritance real is the reference by ID. You give the Organization a stable identifier, then anywhere else that needs it, you point at that identifier instead of copying the whole block. Change your company name once and every page updates, because every page was only ever pointing at the one true copy.
Define the Organization once, point at it everywhere else, and every page on the site stays in sync without you touching a thing.
The part nobody warns you about
Before you write a single line of your own schema, you have to silence everything else. If you skip this, you will emit clean schema and still fail validation, because the plugin is quietly emitting its own broken copy right next to yours. Two Organization blocks, two WebSite blocks, two truths.
Most SEO plugins expose a filter to turn their structured data off. SeoPress, which I use on ampy.se, has one. Yoast and Rank Math have their own. Put these in a small must-use plugin or your theme functions file.
<?php
// Turn OFF SeoPress structured data so we have one source.
add_filter( 'seopress_schemas_auto_enable', '__return_false' );
add_filter( 'seopress_json_ld_enable', '__return_false' );
// If you use Yoast, disable its schema graph too.
add_filter( 'wpseo_json_ld_output', '__return_false' );
// If you use Rank Math, remove its JSON-LD.
add_filter( 'rank_math/json_ld', function ( $data ) {
return array();
} );
Filter names change between plugin versions, so check yours before you trust it. The way to be sure it worked is simple: load a page, view source, and search for the text “application/ld+json”. After this step you want to see zero blocks, because you have not added yours yet. If you still see one, something is still emitting. Track it down before you go on.
After silencing each plugin, view source and search for “application/ld+json” before writing a single line of your own schema. Seeing zero blocks at that point confirms you have a clean slate and will not end up with two conflicting sources side by side.
Building it step by step
Now we build the one source. It is a small class. I keep it in a must-use plugin so it loads on every request and cannot be turned off by a theme switch.
The shape of the class
The class has three jobs. Build the base graph that every page shares. Ask the current page what type it is and let it add its own piece. Print the whole thing into the head as one JSON-LD block. That is all.
<?php
/**
* Plugin Name: Site Schema Generator
* Description: One authoritative JSON-LD source with per-type inheritance.
*/
class Schema_Generator {
public function __construct() {
add_action( 'wp_head', array( $this, 'render' ), 20 );
}
/**
* The base graph every page inherits.
* Organization and WebSite never change page to page,
* so we define them once here.
*/
private function base_graph() {
$site_url = home_url( '/' );
$org_id = $site_url . '#organization';
$site_id = $site_url . '#website';
return array(
array(
'@type' => 'Organization',
'@id' => $org_id,
'name' => get_bloginfo( 'name' ),
'url' => $site_url,
'logo' => array(
'@type' => 'ImageObject',
'url' => get_theme_mod( 'custom_logo_url', $site_url . 'logo.png' ),
),
),
array(
'@type' => 'WebSite',
'@id' => $site_id,
'url' => $site_url,
'name' => get_bloginfo( 'name' ),
'publisher' => array( '@id' => $org_id ),
),
);
}
}
new Schema_Generator();
The role of the @id
Notice the @id values. Each node gets a stable ID built from the site URL. That is the glue of inheritance. When a Product later says its brand is the Organization, it does not repeat the whole Organization. It just points at #organization by ID. Google follows the reference. Define once, reference everywhere. If you ever rename the company or swap the logo, you change it in this one method and every page across the site follows.
Letting each page type add its piece
Now the part that makes it inherit. We ask WordPress what is being viewed, and we merge in the extra node for that type. The base is always there. The extra node is small and only carries what is unique to that page.
<?php
/**
* Build the piece specific to the current page and
* merge it onto the shared base graph.
*/
private function page_graph() {
$graph = $this->base_graph();
if ( is_singular( 'product' ) ) {
$graph[] = $this->product_node();
} elseif ( is_singular( 'post' ) ) {
$graph[] = $this->article_node();
} elseif ( is_page( 'faq' ) ) {
$graph[] = $this->faq_node();
} elseif ( is_singular( 'location' ) ) {
$graph[] = $this->location_node();
}
return $graph;
}
Each of those node methods returns one array. They stay short on purpose. Here is the Product one. It reads its fields from custom fields (I use SCF on ampy.se) and points its brand back at the shared Organization by ID.
<?php
private function product_node() {
$post_id = get_the_ID();
$site_url = home_url( '/' );
$node = array(
'@type' => 'Product',
'@id' => get_permalink( $post_id ) . '#product',
'name' => get_the_title( $post_id ),
'description' => wp_strip_all_tags( get_the_excerpt( $post_id ) ),
// Inherit the shared Organization instead of repeating it.
'brand' => array( '@id' => $site_url . '#organization' ),
);
// Only add an offer if we actually have a price.
$price = get_post_meta( $post_id, 'price', true );
if ( $price ) {
$node['offers'] = array(
'@type' => 'Offer',
'price' => $price,
'priceCurrency' => 'SEK',
'availability' => 'https://schema.org/InStock',
);
}
return $node;
}
The Article and FAQ nodes follow the same pattern. Short, focused, and they lean on the base for anything shared.
<?php
private function article_node() {
$post_id = get_the_ID();
$site_url = home_url( '/' );
return array(
'@type' => 'Article',
'@id' => get_permalink( $post_id ) . '#article',
'headline' => get_the_title( $post_id ),
'datePublished' => get_the_date( 'c', $post_id ),
'dateModified' => get_the_modified_date( 'c', $post_id ),
'author' => array(
'@type' => 'Person',
'name' => get_the_author_meta( 'display_name', get_post_field( 'post_author', $post_id ) ),
),
// Publisher is the shared Organization, by reference.
'publisher' => array( '@id' => $site_url . '#organization' ),
);
}
private function faq_node() {
// Pull question/answer pairs from a repeater field.
$pairs = get_field( 'faq_items' ); // returns array of ['question','answer']
$questions = array();
if ( $pairs ) {
foreach ( $pairs as $pair ) {
$questions[] = array(
'@type' => 'Question',
'name' => $pair['question'],
'acceptedAnswer' => array(
'@type' => 'Answer',
'text' => wp_strip_all_tags( $pair['answer'] ),
),
);
}
}
return array(
'@type' => 'FAQPage',
'mainEntity' => $questions,
);
}
When you add a new page type later, you add one method and one line in page_graph(). You never touch the base. That is the whole point. A junior developer can add a location or a recipe type in ten minutes and cannot break the shared graph, because the shared graph is not something they edit.
Printing it into the head
Last, we wrap the graph in the standard JSON-LD envelope and print it once. One block, one context, every node inside it.
<?php
public function render() {
// Do not emit schema on admin, feeds, or search results.
if ( is_admin() || is_feed() || is_search() ) {
return;
}
$document = array(
'@context' => 'https://schema.org',
'@graph' => $this->page_graph(),
);
$json = wp_json_encode(
$document,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
echo '<script type="application/ld+json">' . $json . '</script>' . "n";
}
Why wp_json_encode and not json_encode
Use wp_json_encode, not raw json_encode. It handles WordPress encoding correctly and keeps special characters, common in Swedish content, from breaking the block. The @graph wrapper is what lets many nodes live in one script tag and reference each other by ID. Without it you would be back to separate blocks that cannot see each other, which is the exact problem we set out to remove.
Prove it works
Do not trust this by eye. Structured data fails in quiet ways, so verify it properly. Check three things, in order, from fastest to slowest.
Check there is exactly one block
Load a product page, view source, and search for “application/ld+json”. You want to find one block, not two. If you find two, an old source is still alive. Go back to the disabling step and finish it.
Run it through the validators
Copy the page URL into two tools. Google’s Rich Results Test tells you whether the page qualifies for stars, prices, and FAQ drop-downs. The Schema.org validator catches structural mistakes Google is quiet about. Run a product page, an article, an FAQ page, and a location page. Fix until all four come back clean.
Watch it in Search Console
The real proof arrives over the following weeks. Google Search Console has an Enhancements section that reports valid items, warnings, and errors across the whole site. On ampy.se, this is where fifteen plus errors dropped to zero and stayed there. Because no human hand edits schema anymore, there is nothing left to break.
The payoff, in business terms
Here is what this actually buys you, past the jargon.
- Your listings in Google can show stars, prices, and expandable questions, so more people click your result instead of a competitor’s, and you paid nothing extra for that attention.
- Every page carries correct, consistent structured data automatically, so a thousand pages behave like one well-built page.
- The site can grow without the schema rotting. New product, new article, new location: it inherits the base and just works.
- You stop firefighting validation errors, because there is only one source and no one hand edits it.
You do not need a premium schema plugin or a monthly service. You need one authoritative source that you control, a base graph defined once, and page types that inherit it and add only their own piece. Build that, and your structured data stays valid forever while quietly pulling in clicks you would otherwise have paid for.