If you are building schema markup into a WordPress site by hand, in a custom theme, a block theme, or a small site-specific plugin, rather than leaning on Yoast or Rank Math to do it for you, this is the reference for getting the four core pieces right and wired together.
Those four pieces are an Organization (who runs the site), a WebSite (the site itself), a WebPage or Article (the page being viewed), and a Person (the author, on posts that have a real one). Done properly, they are not four separate script blocks. They are four nodes in one connected graph, each with a stable identifier, each referencing the others, output once in the page head. This guide covers what each node needs, where in WordPress the data for it actually comes from, and the WordPress-specific things that quietly break it.
It is also a useful audit target if you are not hand-coding. Run your current setup's output against the checklist at the end and you will see quickly whether a plugin is giving you a connected graph or a pile of disconnected blocks.
The shape: one graph, one output point
Before the individual nodes, the structure they sit in.
One @graph, not several blocks. A single <script type="application/ld+json"> holding a @graph array keeps every node in one place, makes the cross-references easy to see, and makes it much harder to end up with two nodes describing the same thing. Google documents no preference between one block and several, so this is a maintainability choice, not a correctness one. The patterns for combining types this way are covered in our guide to multiple schema types on one page.
Output it on wp_head. That hook fires on every page, in both classic and block themes, and it puts the markup in the raw HTML that crawlers read first. Do not attach schema output to wp_body_open or a shortcode or a block, those are less reliable across theme types and can land the markup in a place a crawler reads late or not at all.
Give every node a stable @id. The @id is what turns four descriptions into one connected picture. Use your real URLs with a fragment on the end, and be consistent about trailing slashes so the identifiers match everywhere they appear:
| Node | @id pattern | WordPress source |
|---|---|---|
| Organization | https://example.com/#organization | home_url( '/' ) plus #organization |
| WebSite | https://example.com/#website | home_url( '/' ) plus #website |
| WebPage | the page URL plus #webpage | get_permalink() (or home_url() on the front page) |
| Article | the post URL plus #article | get_permalink() |
| Person | the author archive URL plus #author | get_author_posts_url( $id ) |
The rule for the rest of this guide: a node is described in full once, and everywhere else it is referenced by { "@id": "..." } and nothing more.
Node 1: Organization
This is the identity node. It describes the business or person behind the site, it is referenced by the WebSite as publisher and by every Article as publisher, and it is the node AI systems resolve when they try to work out who is accountable for the content.
Describe it in full in the graph on every page (it is referenced everywhere, so it needs to exist everywhere), and keep the description identical page to page.
| Property | WordPress source | Notes |
|---|---|---|
@type | fixed, "Organization" | Or a subtype. See the LocalBusiness note below. |
@id | home_url( '/' ) + #organization | |
name | get_bloginfo( 'name' ) | The site title in Settings, General. |
url | home_url( '/' ) | |
logo | see below | An ImageObject, not a bare string, if you can. |
description | a theme option or get_bloginfo( 'description' ) | The tagline is usually too short to be a real description; a dedicated option is better. |
sameAs | a theme option or custom fields | The array of official profile URLs. |
The logo needs care. Google's Organization documentation states the image "must be 112x112px, at minimum," "must be crawlable and indexable," in a "file format supported by Google Images," and adds: "Make sure the image looks how you intend it to look on a purely white background." WordPress gives you get_site_icon_url(), which returns the Site Icon at up to 512px square, and that clears the size bar. But a Site Icon is set up as a favicon: often a cropped mark, sometimes on a coloured background, not always the full logo you want in a knowledge panel. Treat get_site_icon_url() as a fallback and give the site a dedicated "schema logo" image option where the brand actually matters. Whichever you use, output it as an ImageObject with its own @id so the Article can point at the same node:
{
"@type": "ImageObject",
"@id": "https://example.com/#logo",
"url": "https://example.com/wp-content/uploads/logo.png",
"contentUrl": "https://example.com/wp-content/uploads/logo.png",
"width": 512,
"height": 512
}Organization has no required properties. Google says so directly: "There are no required properties; instead, add the properties that apply to your organization." So there is no minimum block to satisfy, the job is to describe the entity as completely and accurately as the site genuinely supports. The property-by-property identity layer (sameAs, knowsAbout, identifiers, contact points, the founder and publisher patterns) is its own topic; our Organization schema guide and our sameAs in WordPress guide go through it in full.
If the site is for a business with a real premises, this node's @type is not Organization, it is the most specific LocalBusiness subtype that fits, and it gains address, geo, and openingHoursSpecification. It stays one node, it does not become a second block. That decision, and why publishing a separate Organization and LocalBusiness is the common mistake, is covered in Organization vs LocalBusiness.
Node 2: WebSite
The WebSite node describes the site as a whole and is where Google reads your site name for search results.
Output it in full only on the front page. Google's site names documentation is specific: "The WebSite structured data must be on the home page of the site. By home page, we mean the domain or subdomain level root URI." It also says: "avoid creating an additional WebSite structured data block on your home page if you can help it." So gate it:
if ( is_front_page() ) {
// add the WebSite node to the graph
}Note is_front_page(), not is_home(). On a site with a static front page, is_home() is the blog posts index, which is not the site root. Getting this wrong is how the WebSite node ends up on the wrong page or on none.
| Property | WordPress source | Notes |
|---|---|---|
@type | fixed, "WebSite" | |
@id | home_url( '/' ) + #website | |
name | get_bloginfo( 'name' ) | Required by Google. |
url | home_url( '/' ) | Required by Google. |
alternateName | a theme option | Recommended: an acronym or short name. |
publisher | { "@id": ".../#organization" } | Reference, not a copy. |
You will see older guides add a potentialAction with a SearchAction for the sitelinks search box. Google removed that feature in 2024. The markup still validates and does no harm, but it produces nothing, so there is no reason to add it to new code.
Node 3: WebPage and Article
Two nodes here, and which ones you output depends on the page.
WebPage: on every content page
A WebPage node describes the specific page being viewed and connects it up to the site.
| Property | WordPress source | Notes |
|---|---|---|
@type | "WebPage", or a subtype like "AboutPage", "ContactPage" | |
@id | get_permalink() + #webpage (or home_url() on the front page) | |
url | get_permalink() | |
name | wp_get_document_title() or get_the_title() | |
isPartOf | { "@id": ".../#website" } | Connects the page to the site. |
datePublished | get_the_date( 'c' ) | ISO 8601 with timezone. |
dateModified | get_the_modified_date( 'c' ) | ISO 8601 with timezone. |
breadcrumb | { "@id": "...#breadcrumb" } | If you output a BreadcrumbList node. |
primaryImageOfPage | { "@id": "...#primaryimage" } | The page's main image, as an ImageObject. |
Article: on posts with real article content
On single blog posts (is_singular( 'post' ), and any custom post type that is genuinely article-like), add an Article node as well, with the WebPage as its mainEntityOfPage.
Google's Article documentation supports three type values: Article, NewsArticle, and BlogPosting. Use BlogPosting for a blog, NewsArticle for dated news reporting, Article when neither fits. There are, again, "no required properties," but the ones that carry weight are:
| Property | WordPress source | Notes |
|---|---|---|
@type | "BlogPosting" / "Article" / "NewsArticle" | Pick one, consistently, per post type. |
@id | get_permalink() + #article | |
headline | get_the_title() | Google: "Consider using a concise title, as long titles may be truncated on some devices." |
image | get_the_post_thumbnail_url( $id, 'full' ) | See the image note below. |
datePublished | get_the_date( 'c' ) | ISO 8601 with timezone. |
dateModified | get_the_modified_date( 'c' ) | ISO 8601 with timezone. |
author | { "@id": ".../author-slug/#author" } | A reference to the Person node. Never a bare string. |
publisher | { "@id": ".../#organization" } | A reference to the Organization node. |
mainEntityOfPage | { "@id": "...#webpage" } |
On the dates. get_the_date( 'c' ) and get_the_modified_date( 'c' ) return the format Google asks for: ISO 8601 with the site's timezone offset included. Google's note: "provide timezone information; otherwise, we will default to the timezone used by Googlebot." The WordPress-specific trap is that dateModified moves every time anyone saves the post, including a one-word typo fix, so it can drift far from the last meaningful update. What each date means, what Google and AI engines do with them, and why bumping a date without changing the content backfires, is covered in our guide to publish and modified dates.
On the image. Google recommends multiple images at 16:9, 4:3, and 1:1, each at least 50,000 pixels when you multiply width by height, and the image "must represent the marked up content." A single small thumbnail passed as the only image value validates but is weak. If your theme generates several crop sizes, pass an array.
On the author. It must be a Person or Organization node, referenced by @id, not the author's name as a string. That is node 4.
Node 4: Person (the author)
Output a Person node only where there is a real, named human author whose identity you can describe. On a single-author brand blog where "the author" is really just the company, skip the Person node and set the Article's author to the Organization's @id instead. A thin Person node with only a name is weaker than an honest Organization author.
Where there is a real author:
| Property | WordPress source | Notes |
|---|---|---|
@type | fixed, "Person" | |
@id | get_author_posts_url( $id ) + #author | |
name | get_the_author_meta( 'display_name', $id ) | |
url | get_author_posts_url( $id ) | The author archive page. |
description | get_the_author_meta( 'description', $id ) | The "Biographical Info" field; often empty, worth prompting authors to fill. |
sameAs | user profile fields or custom fields | The author's own professional profiles. |
The @id here points at the WordPress author archive URL. That is a deliberate, stable choice, but it depends on that archive actually existing. Some themes and SEO plugins disable author archives (or noindex them), and if the archive returns a 404, your @id now points at nothing, which is a dangling reference, and a reference that resolves to nothing is treated as worse than no reference at all. If you disable author archives, use a stable non-URL identifier for the Person instead, for example https://example.com/#/schema/person/3, and make sure nothing else expects to fetch it.
How much of an author to describe, and when credentials and an author's own sameAs links are worth adding for E-E-A-T, is a judgement call worth making deliberately rather than filling every field you can.
Wiring it together
The output function, in outline. One callback on wp_head, building the @graph conditionally:
add_action( 'wp_head', function () {
$org_id = home_url( '/#organization' );
$site_id = home_url( '/#website' );
$graph = [];
// Organization: always, described in full.
$graph[] = [
'@type' => 'Organization',
'@id' => $org_id,
'name' => get_bloginfo( 'name' ),
'url' => home_url( '/' ),
'logo' => [ '@type' => 'ImageObject', '@id' => home_url( '/#logo' ), /* ... */ ],
// sameAs, description from options
];
// WebSite: front page only.
if ( is_front_page() ) {
$graph[] = [
'@type' => 'WebSite',
'@id' => $site_id,
'name' => get_bloginfo( 'name' ),
'url' => home_url( '/' ),
'publisher' => [ '@id' => $org_id ],
];
}
// WebPage + Article on single posts.
if ( is_singular() ) {
$page_id = get_permalink() . '#webpage';
$graph[] = [
'@type' => 'WebPage',
'@id' => $page_id,
'url' => get_permalink(),
'name' => wp_get_document_title(),
'isPartOf' => [ '@id' => $site_id ],
'datePublished'=> get_the_date( 'c' ),
'dateModified' => get_the_modified_date( 'c' ),
];
if ( is_singular( 'post' ) ) {
$author_id = get_author_posts_url( (int) get_post_field( 'post_author' ) ) . '#author';
$graph[] = [
'@type' => 'BlogPosting',
'@id' => get_permalink() . '#article',
'headline' => get_the_title(),
'datePublished' => get_the_date( 'c' ),
'dateModified' => get_the_modified_date( 'c' ),
'author' => [ '@id' => $author_id ],
'publisher' => [ '@id' => $org_id ],
'mainEntityOfPage' => [ '@id' => $page_id ],
];
$graph[] = [
'@type' => 'Person',
'@id' => $author_id,
'name' => get_the_author_meta( 'display_name', (int) get_post_field( 'post_author' ) ),
'url' => get_author_posts_url( (int) get_post_field( 'post_author' ) ),
];
}
}
if ( $graph ) {
$json = wp_json_encode(
[ '@context' => 'https://schema.org', '@graph' => $graph ],
JSON_UNESCAPED_UNICODE
);
// Neutralise a literal "<" (and so "</script>") inside any string value.
$json = str_replace( array( '<', '>', '&' ), array( '\u003C', '\u003E', '\u0026' ), $json );
echo '<script type="application/ld+json">' . $json . '</script>' . "\n";
}
} );wp_json_encode() leaves forward slashes escaped as \/, which is valid JSON and is what stops a URL or a string value from closing the <script> tag early. The extra str_replace covers the one case that still gets through: a literal < inside a value (an author bio containing <script>, say). Do not add JSON_UNESCAPED_SLASHES for readability here; the escaped slashes are load-bearing.
The WordPress-specific things that break it
is_front_page() vs is_home(). On a static front page setup they are different pages. WebSite belongs on is_front_page(). The Article-index checks belong on is_home().
Site Icon standing in for a logo. get_site_icon_url() clears Google's size bar but a favicon crop is not always the logo you want representing the brand. Give the site a real logo option where it matters.
The auto-generated description. Pulling description from the tagline, or an Organization description from the first sentence of a page, produces filler. Use a written option.
A disabled author archive. If the theme or an SEO plugin turns off or noindexes author archives, a Person @id built from get_author_posts_url() points at a dead URL. Either keep the archive or switch to a stable non-URL @id.
A plugin already emitting schema. If Yoast, Rank Math, or AIOSEO is active with its schema feature on, your hand-rolled graph is the second source on every page, and now there are two Organization nodes, two WebSite nodes, and a machine has to guess which is real. Turn the plugin's schema output off (not the whole plugin) before adding your own. This whole failure mode is covered in duplicate schema from two WordPress sources.
Trailing slashes. If your permalinks add a trailing slash, get_permalink() returns it, so get_permalink() . '#article' is fine, but any @id you build by hand must match. An @id of .../post#article referenced elsewhere as .../post/#article is two different identifiers, and the reference dangles.
Relative image URLs. Every image URL in the markup must be absolute (https://), not a path. get_site_icon_url() and get_the_post_thumbnail_url() return absolute URLs; a hardcoded /wp-content/... does not.
Page caching. A full-page cache serves the old HTML, including the old dateModified, until the post's cache entry is purged. Usually fine, worth knowing when a fresh edit's date does not show up in a test.
Block themes. Everything above still works, because it hangs off wp_head. Do not move it to a block or a template part.
The pre-flight checklist
Run this on any site, hand-coded or plugin-driven:
- View source on the front page, a blog post, and a plain page. Not the browser inspector, the raw source. Search for
application/ld+json. - One block, one graph. You want a single script block holding one
@graph. Several blocks from one source is a maintainability problem; several blocks from different sources is a duplication problem. - One of each identity node. Exactly one
Organization, andWebSiteonly on the front page. Two of either means two sources are running. - Every reference resolves. For each
{ "@id": "..." }, confirm a node in the graph actually declares that@id. A reference to an@idnothing defines is a dangling reference. - Dates are ISO 8601 with a timezone.
2026-09-09T04:00:00+00:00, notSeptember 9, 2026. - The author is a node, not a string.
"author": { "@id": "..." }pointing at a realPersonorOrganization, never"author": "Jane Doe". - Run both validators. Google's Rich Results Test for eligibility and syntax, and the Schema Markup Validator for the vocabulary Google does not show a rich result for but AI systems still read.
- Re-check after a theme switch or a plugin change. Those are the events that reintroduce a second schema source or break a conditional.
Frequently Asked Questions
AI Schema Gen builds this graph for you from your live WordPress content: one connected @graph per page, the right node types for your site, @id cross-references wired up, and the duplicate output from Yoast, Rank Math, and AIOSEO suppressed automatically. Run a free check at aischemagen.com to see what your site currently emits.
Is your site ready for AI?
Get a free readiness score in under a minute. No signup, no card.
Run the free check