Structured data helps search engines understand who published a page, what it is about and how it relates to the rest of your site. This guide shows how to write schema markup in JSON-LD properly: linking entities with @graph and @id, choosing the right types, and adding it to WordPress and Next.js without duplicating what your SEO plugin already outputs.
One expectation to set early: structured data makes a page eligible for rich results; it does not guarantee them, and it is not a direct ranking boost. Its value is clarity, and clarity compounds.
JSON-LD vs microdata vs RDFa
Schema.org vocabulary can be expressed in three syntaxes:
- Microdata uses
itemscope,itemtypeanditempropattributes woven into your HTML. - RDFa does something similar with
vocab,typeofandpropertyattributes. - JSON-LD is a separate JSON block inside a
<script type="application/ld+json">tag.
Google supports all three but recommends JSON-LD, and I use it exclusively. It keeps markup separate from templates, so a redesign cannot silently break it, and it is easy to generate from data you already have in PHP or JavaScript. The rule that still applies: what you describe in JSON-LD must match what users see on the page.
Build a connected graph with @graph and @id
Many sites output several disconnected JSON-LD blocks: one for the organisation, one for the article, one for breadcrumbs. Search engines then have to guess that the article’s publisher is the same organisation described elsewhere. A better approach is a single @graph array where each entity has a stable @id and references other entities by that ID.
An @id is just a unique URI. The convention is the page or site URL plus a fragment, such as https://example.com/#organization. It does not need to resolve to anything; it only needs to be consistent on every page.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Example Studio",
"url": "https://example.com/",
"logo": "https://example.com/logo.png",
"sameAs": [
"https://www.linkedin.com/company/example-studio",
"https://github.com/example-studio"
]
},
{
"@type": "WebSite",
"@id": "https://example.com/#website",
"url": "https://example.com/",
"name": "Example Studio",
"publisher": { "@id": "https://example.com/#organization" }
},
{
"@type": "WebPage",
"@id": "https://example.com/blog/schema-guide/#webpage",
"url": "https://example.com/blog/schema-guide/",
"name": "Schema markup guide",
"isPartOf": { "@id": "https://example.com/#website" },
"breadcrumb": { "@id": "https://example.com/blog/schema-guide/#breadcrumb" }
}
]
}
Now the WebPage belongs to the WebSite, which is published by the Organization, and every page on the site reuses the same organisation and website IDs.
The core types most sites need
Person and Organization
Use Organization for a business and Person for an individual, such as a freelancer, consultant or article author. A personal brand site can have the Person as publisher. Include sameAs links to official profiles so search engines can connect the entity to its presence elsewhere.
WebSite and WebPage
WebSite describes the site once. Every URL gets a WebPage (or a subtype like AboutPage, ContactPage or CollectionPage) that is isPartOf the WebSite.
BreadcrumbList
Breadcrumbs describe where the page sits in your hierarchy. Keep them consistent with any visible breadcrumb trail.
{
"@type": "BreadcrumbList",
"@id": "https://example.com/blog/schema-guide/#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://example.com/blog/" },
{ "@type": "ListItem", "position": 3, "name": "Schema markup guide" }
]
}
Article
For blog posts use Article or BlogPosting, linking the author and publisher by ID and pointing mainEntityOfPage at the WebPage.
{
"@type": "BlogPosting",
"@id": "https://example.com/blog/schema-guide/#article",
"headline": "Schema markup JSON-LD guide",
"datePublished": "2026-11-07T09:00:00+05:30",
"dateModified": "2026-11-07T09:00:00+05:30",
"author": { "@id": "https://example.com/#person" },
"publisher": { "@id": "https://example.com/#organization" },
"mainEntityOfPage": { "@id": "https://example.com/blog/schema-guide/#webpage" },
"image": "https://example.com/images/schema-guide.jpg"
}
Product
Product markup matters most for e-commerce. Include an Offer with price, currency and availability, and keep the price in sync with what the page shows; mismatches are a common cause of warnings in Search Console’s merchant reports.
{
"@type": "Product",
"@id": "https://example.com/product/oak-desk/#product",
"name": "Oak standing desk",
"image": ["https://example.com/images/oak-desk.jpg"],
"sku": "DESK-OAK-120",
"brand": { "@type": "Brand", "name": "Example Furniture" },
"offers": {
"@type": "Offer",
"url": "https://example.com/product/oak-desk/",
"price": "499.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
}
}
FAQPage, with a caveat
Since 2023 Google has limited FAQ rich results to well-known, authoritative government and health websites, so most sites will no longer see the expandable FAQ in search results. FAQPage markup is still valid schema.org and other consumers may use it, so I keep it on pages that genuinely have a question-and-answer section, but I don’t add FAQs to pages just to chase a rich result. Google has also retired several other rich result types over time, so check its current search gallery before investing in a type for visual results alone.
Adding JSON-LD in WordPress
Yoast SEO and Rank Math already output a connected @graph for Organization or Person, WebSite, WebPage, BreadcrumbList and Article. Adding a second hand-written graph usually creates duplicate, conflicting entities. Extend the plugin’s graph instead.
<?php
// Yoast SEO: add a property to the existing Article piece.
add_filter( 'wpseo_schema_article', function ( array $data ) {
$data['about'] = [
'@type' => 'Thing',
'name' => 'Structured data',
];
return $data;
} );
Rank Math exposes a similar rank_math/json_ld filter. Check each plugin’s developer docs for the current hooks. On a site without an SEO plugin, or for a custom post type the plugin doesn’t understand, output your own block:
<?php
add_action( 'wp_head', function () {
if ( ! is_singular( 'course' ) ) {
return;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Course',
'@id' => get_permalink() . '#course',
'name' => get_the_title(),
'description' => wp_strip_all_tags( get_the_excerpt() ),
'provider' => [ '@id' => home_url( '/#organization' ) ],
];
printf(
'<script type="application/ld+json">%s</script>' . "n",
wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG )
);
} );
JSON_HEX_TAG escapes < and > so content containing </script> cannot break out of the tag. If you store fields with ACF, as in my custom post types and ACF guide, map those fields straight into the array.
Adding JSON-LD in Next.js
In the App Router, render a script tag in the page or layout server component. Build the object from the same data you render, and escape < as the Next.js docs recommend:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts';
const SITE = 'https://example.com';
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
const url = `${SITE}/blog/${slug}/`;
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BlogPosting',
'@id': `${url}#article`,
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@id': `${SITE}/#person` },
publisher: { '@id': `${SITE}/#person` },
mainEntityOfPage: { '@id': `${url}#webpage` },
},
],
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\u003c'),
}}
/>
<h1>{post.title}</h1>
{/* … */}
</article>
);
}
Put the site-wide Person or Organization and WebSite entities in the root layout so every page shares them, and add page-specific entities in each route. In older Next.js versions params is a plain object rather than a promise; adjust to your version. For the rest of the SEO setup, see my Next.js technical SEO checklist.
Validating and monitoring
- Rich Results Test (search.google.com/test/rich-results) shows which Google rich result types a page is eligible for and flags errors in those types. It renders JavaScript, so it works for client-rendered markup.
- Schema Markup Validator (validator.schema.org) checks general schema.org validity, including types Google does not use for rich results.
- Search Console enhancement reports track errors and warnings across the whole site over time, which catches template regressions a single-URL test would miss.
Common mistakes I look for: markup describing content that isn’t visible, prices or ratings that don’t match the page, multiple conflicting Organization entities, relative URLs in @id values, and review markup on a business’s own reviews of itself, which Google does not show as review snippets.
Key takeaways
- Use JSON-LD; it is Google’s recommended format and is independent of your templates.
- Connect entities in one
@graphwith stable@idURIs reused across the site. - Cover the basics first: Person or Organization, WebSite, WebPage, BreadcrumbList, then Article or Product.
- FAQ rich results are restricted to a small set of authoritative sites; markup alone won’t bring them back.
- In WordPress, extend your SEO plugin’s graph rather than duplicating it; in Next.js, render escaped JSON in server components.
- Validate with the Rich Results Test and Schema Markup Validator, then monitor Search Console.
Frequently asked questions
Does schema markup improve rankings?
Google has said structured data is not a general ranking factor. It helps search engines understand your content and can make pages eligible for rich results, which may improve how your listing looks and how often it is clicked.
Can I have multiple JSON-LD script tags on one page?
Yes, multiple blocks are valid, but entities should still reference each other with @id and must not contradict each other. A single @graph is easier to maintain and avoids duplicate entities.
Should I still add FAQPage schema?
Only where the page genuinely contains questions and answers. Google now shows FAQ rich results only for well-known government and health sites, so for most sites it is descriptive markup rather than a route to a rich result.
Does Google read JSON-LD injected with JavaScript?
Google can process JSON-LD added by JavaScript after rendering, but server-rendered markup is more reliable and is visible to other crawlers that don’t execute JavaScript. Prefer outputting it in the initial HTML.
If you want structured data designed and implemented across a WordPress or Next.js site, get in touch and I can audit what you have and build a clean, connected graph.