How to Add Schema Code in WordPress Without a Plugin

Working, tested PHP for adding JSON-LD to WordPress by hand: conditionals, safe encoding, and a complete Article, Organization and BreadcrumbList graph in 1.1 KB versus Yoast's 4 KB.

There are three good reasons to write your schema by hand rather than install a plugin: you want exact control over the graph, you're already carrying enough plugins, or you need a type no plugin supports.

There's one bad reason, which is that you think plugins are bloated. Mostly they aren't, but the numbers are on your side here. On an identical test post, the hand-written graph below produced 1,168 bytes of complete, valid JSON-LD covering Article, Organization, BreadcrumbList, Person and ImageObject. Yoast SEO, fully configured, produced 4,029 bytes on the same page. Same required properties, 71% less markup.

Everything on this page was run on a real WordPress 7.0.2 install and the output verified before publishing. If you're not sure a manual approach is right for you yet, start with our guide to adding schema markup in WordPress. If you'd rather not maintain code at all, our tested plugin comparison covers the alternatives.

Where the Code Should Go

This decides how much trouble you're in later, so get it right first.

Where to put hand-written schema code
Location Survives theme updates Survives theme switch Use when
Parent theme functions.phpNever
Child theme functions.phpThe schema is genuinely theme-specific
mu-plugins directoryRecommended for most sites
Code snippet plugin (WPCode etc.)Non-developers, or when you need an admin UI
⚠️
Never edit the parent theme The next update overwrites it and your structured data vanishes with no notice.

For a site you control, the cleanest option is a must-use plugin. Create wp-content/mu-plugins/your-schema.php. WordPress loads anything in that directory automatically, it can't be deactivated by accident, and it's completely independent of the theme.

<?php
/**
 * Plugin Name: Site Schema
 * Description: Hand-written JSON-LD, no schema plugin required.
 */

That header is all the boilerplate a must-use plugin needs.

The Hook: Where JSON-LD Belongs

JSON-LD goes in the <head>, which in WordPress means the wp_head action:

add_action( 'wp_head', 'site_output_schema', 20 );

The priority of 20 matters more than it looks. It puts your output after the title tag and meta description, which keeps the source readable, and after most SEO plugins have run, which is useful if you're diagnosing conflicts.

JSON-LD is also valid at the end of <body>, via wp_footer. Google reads both. wp_head is the convention and there's no reason to deviate.

Conditionals: Only Output What Applies

The single most common mistake in hand-written schema is emitting Article markup on category archives, or an Organization node on every URL including the 404 page. WordPress gives you precise conditional tags, so use them.

function site_output_schema() {

	$graph = array();

	// Article on single blog posts only.
	if ( is_singular( 'post' ) ) {
		$graph[] = site_article_schema( get_queried_object() );
	}

	// Organization on every page: other nodes reference it by @id.
	$graph[] = site_organization_schema();

	// Breadcrumbs on any single post or page except the homepage.
	if ( is_singular() && ! is_front_page() ) {
		$graph[] = site_breadcrumb_schema();
	}

	$graph = array_filter( $graph );

	if ( empty( $graph ) ) {
		return;
	}

	$payload = array(
		'@context' => 'https://schema.org',
		'@graph'   => array_values( $graph ),
	);

	echo '<script type="application/ld+json">'
		. wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
		. '</script>' . "\n";
}

The conditionals worth knowing:

WordPress conditional tags for schema output
Tag Matches
is_singular( 'post' )A single blog post
is_singular( 'product' )A single WooCommerce product
is_page()Any static page
is_front_page()The homepage, whatever it's set to
is_home()The blog posts index (not always the homepage)
is_archive() / is_category()Archive listings
is_singular()Any single post, page or custom post type

One exception worth flagging on is_singular( 'product' ): don't hand-write Product schema for WooCommerce. Woo already emits a valid Product node with no plugin at all, so a manual one gives you two competing Product nodes on the same page. Extend Woo's output through its own filter instead, which we cover in the WooCommerce schema markup guide.

is_home() and is_front_page() catch people out constantly. If your homepage is a static page, is_front_page() is true and is_home() is false. If it shows your latest posts, both are true.

Escaping: The Part Most Tutorials Get Wrong

Almost every "add schema without a plugin" tutorial online shows you a JSON string with PHP variables interpolated directly into it, like "headline": "<?php the_title(); ?>". Don't do that. A post title containing an apostrophe, a quotation mark or a dash will break the JSON, and the whole block silently stops being parsed.

Build a PHP array and let WordPress encode it:

wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )

wp_json_encode() handles escaping correctly. The two flags are worth having:

  • JSON_UNESCAPED_SLASHES keeps URLs as https://example.com rather than https:\/\/example.com. Both are valid; one is readable.
  • JSON_UNESCAPED_UNICODE keeps accented characters and non-Latin scripts as themselves rather than é escapes. Important if you publish in anything other than plain English.

And sanitise the values going in. Use wp_strip_all_tags() on anything derived from post content, because a title with inline HTML will produce markup that doesn't match the visible text:

'headline' => wp_strip_all_tags( get_the_title( $post ) ),

A Complete, Working Article Graph

This is the full function. It reads real post data, with no hardcoded values, and resolves the author and featured image properly.

function site_article_schema( $post ) {

	if ( ! $post instanceof WP_Post ) {
		return null;
	}

	$author_id = (int) $post->post_author;
	$image_id  = get_post_thumbnail_id( $post );

	$schema = array(
		'@type'            => 'Article',
		'@id'              => get_permalink( $post ) . '#article',
		'headline'         => wp_strip_all_tags( get_the_title( $post ) ),
		'description'      => wp_strip_all_tags( get_the_excerpt( $post ) ),
		'datePublished'    => get_the_date( DATE_W3C, $post ),
		'dateModified'     => get_the_modified_date( DATE_W3C, $post ),
		'mainEntityOfPage' => array( '@id' => get_permalink( $post ) ),
		'inLanguage'       => get_bloginfo( 'language' ),
		'author'           => array(
			'@type' => 'Person',
			'@id'   => get_author_posts_url( $author_id ) . '#person',
			'name'  => get_the_author_meta( 'display_name', $author_id ),
			'url'   => get_author_posts_url( $author_id ),
		),
		'publisher'        => array( '@id' => home_url( '/' ) . '#organization' ),
	);

	if ( $image_id ) {
		$src = wp_get_attachment_image_src( $image_id, 'full' );
		if ( $src ) {
			$schema['image'] = array(
				'@type'  => 'ImageObject',
				'url'    => $src[0],
				'width'  => $src[1],
				'height' => $src[2],
			);
		}
	}

	return $schema;
}

Four details that matter:

  • DATE_W3C produces the ISO 8601 format Google expects (2026-07-30T23:15:34+00:00). Passing a human-readable date format here is a common and silent failure.
  • get_the_author_meta( 'display_name', $author_id ), not get_the_author(). Outside the loop, get_the_author() returns nothing. This is exactly the bug that makes some plugins output an empty or null author.
  • publisher is a reference, not an inline object. It points at the Organization node by @id, which is why that node has to exist on the same page.
  • The image is conditional. No featured image means no image property, which is better than an image property pointing at nothing.

Organization and BreadcrumbList

function site_organization_schema() {
	return array(
		'@type' => 'Organization',
		'@id'   => home_url( '/' ) . '#organization',
		'name'  => get_bloginfo( 'name' ),
		'url'   => home_url( '/' ),
		'logo'  => array(
			'@type' => 'ImageObject',
			'url'   => get_site_icon_url( 512 ),
		),
		'sameAs' => array(
			'https://www.linkedin.com/company/your-company',
			'https://x.com/yourhandle',
		),
	);
}

sameAs is the most under-used property in WordPress schema. It's how you tell Google that this Organization is the same entity as your LinkedIn page, your company register listing, your Crunchbase profile. For a small brand trying to establish that it exists as a recognised entity, it does more than most of the rest of the graph combined.

function site_breadcrumb_schema() {

	$items = array(
		array(
			'@type'    => 'ListItem',
			'position' => 1,
			'name'     => 'Home',
			'item'     => home_url( '/' ),
		),
	);

	$items[] = array(
		'@type'    => 'ListItem',
		'position' => 2,
		'name'     => wp_strip_all_tags( get_the_title() ),
	);

	return array(
		'@type'           => 'BreadcrumbList',
		'@id'             => get_permalink() . '#breadcrumb',
		'itemListElement' => $items,
	);
}

The final item deliberately has no item property. Google's guidance is that the last breadcrumb, the current page, shouldn't link to itself.

Why @id and @graph Matter

You could emit three separate <script type="application/ld+json"> blocks and it would validate. Don't.

A single @graph with @id references tells Google these nodes describe one page and one entity. Separate blocks with inline duplicates tell it there might be several unrelated things. Every well-built plugin (Yoast, Rank Math, All in One SEO, Slim SEO) uses a linked graph. It's the pattern to copy.

The rule that follows from it: every @id you reference must resolve to a node on the same page. If your Article node says "publisher": {"@id": "https://example.com/#organization"} and the Organization node only exists on the homepage, you have a dangling reference. That's why the code above outputs the Organization node on every page rather than gating it behind is_front_page(), a mistake we made in the first draft of this exact snippet and caught in testing.

What This Produces

Running the complete code on a test post with a featured image and an assigned author:

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Article",
      "@id": "https://example.com/schema-test-post/#article",
      "headline": "How to Add Schema Markup in WordPress",
      "description": "A test post used to measure what structured data each plugin emits.",
      "datePublished": "2026-07-30T23:15:34+00:00",
      "dateModified": "2026-07-30T23:22:32+00:00",
      "mainEntityOfPage": { "@id": "https://example.com/schema-test-post/" },
      "inLanguage": "en-US",
      "author": {
        "@type": "Person",
        "@id": "https://example.com/author/jane/#person",
        "name": "Jane Tester",
        "url": "https://example.com/author/jane/"
      },
      "publisher": { "@id": "https://example.com/#organization" },
      "image": {
        "@type": "ImageObject",
        "url": "https://example.com/wp-content/uploads/2026/07/featured.png",
        "width": 1200,
        "height": 630
      }
    },
    {
      "@type": "Organization",
      "@id": "https://example.com/#organization",
      "name": "Schema Bench",
      "url": "https://example.com/"
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://example.com/schema-test-post/#breadcrumb",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
        { "@type": "ListItem", "position": 2, "name": "How to Add Schema Markup in WordPress" }
      ]
    }
  ]
}

Seven typed nodes, 1,168 bytes, every property Google requires for Article present and correct.

Note what isn't in there: no FAQPage and no HowTo. Neither produces a rich result any more, so hand-writing them costs you bytes and buys nothing. The deprecation detail is here, including what to do with markup you've already deployed.

Byte weight comparison on the same test post
Source Nodes JSON-LD size
This code71,168 B
Yoast SEO 28.1 (configured)164,029 B
All in One SEO 4.9.10163,641 B
Slim SEO 4.9.11123,243 B
Rank Math 1.0.27572,286 B

The plugins aren't wasting three kilobytes on nothing. They're adding potentialAction, SearchAction, EntryPoint and PropertyValueSpecification scaffolding, plus WebPage and WebSite nodes. Some of that is genuinely useful. None of it is required for Article rich results.

Critical: Turn Off Your Plugin's Schema First

If you have an SEO plugin active and you add this code, you will emit two competing Article nodes. We measured what that looks like: Yoast and All in One SEO running together produced two JSON-LD blocks, two Article nodes, two Organization nodes and two WebPage nodes describing the same page, at 7,670 bytes of contradictory markup.

Google's guidance is that conflicting duplicate markup makes a page ineligible, not doubly eligible.

Disable your plugin's schema output before adding your own.

Yoast, removing the graph pieces you're replacing:

add_filter( 'wpseo_schema_graph_pieces', '__return_empty_array', 11 );

Rank Math:

add_filter( 'rank_math/json_ld', '__return_empty_array', 99 );

All in One SEO:

add_filter( 'aioseo_schema_output', '__return_empty_array' );

All three filters work, we tested each one. Two caveats worth knowing:

  • Yoast and All in One SEO leave an empty script tag behind. Yoast emits {"@context":"https://schema.org","@graph":[]}, which is harmless and ignored by Google, but it'll confuse you when you're counting ld+json blocks in the source. Rank Math removes its block cleanly.
  • wpseo_schema_graph_pieces returning an empty array removes all of Yoast's schema, including breadcrumbs and the WebSite node. If you only want to replace the Article piece, filter the array selectively rather than emptying it.

Or use a plugin that never emitted schema in the first place. In our testing, SEOPress free outputs no content schema at all, which makes it an unexpectedly clean partner for hand-written markup. The full picture is in our tested plugin comparison.

Validate Before You Ship

  1. Google's Rich Results Test tells you whether the markup qualifies for a rich result. This is the one that matters.
  2. Schema Markup Validator checks the markup against schema.org itself, without Google's eligibility filter. Use it when the Rich Results Test reports nothing and you need to know whether the syntax is even valid.
  3. View source and count ld+json blocks. If there's more than one, something else is still emitting.
  4. Test a post, a page and the homepage. Conditional bugs only show up when you check more than one template.
  5. Test a post with an apostrophe in the title. This is the single fastest way to catch an escaping problem.

Once it's live, Search Console's Enhancements reports will show errors across the whole site over the following weeks, but they lag, so don't treat an empty report on day one as a pass. For the errors you're most likely to hit, see our schema errors guide.

When to Stop Doing This by Hand

Hand-written schema is the right answer for a handful of templates on a site you control. It stops being the right answer when:

  • You need per-post overrides an editor can set without touching code
  • Your schema needs to vary across dozens of post types or taxonomies
  • More than one person maintains the site
  • You're managing structured data across hundreds or thousands of URLs

At that point you want either a plugin with a proper UI or a systematic approach, covered in schema markup at scale.

FAQ: Manual Schema in WordPress

How do I add schema code in WordPress without a plugin?

Hook a function to wp_head, build a PHP array describing the page, and output it with wp_json_encode() inside a <script type="application/ld+json"> tag. Put the code in a must-use plugin or a child theme, never the parent theme.

Is manual schema better than a plugin?

It's smaller and gives exact control. In our test, a hand-written graph covered every required Article property in 1,168 bytes against Yoast's 4,029. But it needs maintaining, and a mistake fails silently. Plugins are the right default for most sites.

Where exactly do I put JSON-LD in WordPress?

In the <head>, via the wp_head action at priority 20. The end of <body> via wp_footer also works, since Google reads both.

Why isn't my manual schema showing in the Rich Results Test?

Most often a JSON syntax error from unescaped quotes in a post title, a conditional that doesn't match the page you're testing, or an @id reference pointing at a node that isn't on the page. Check the Schema Markup Validator first, since it'll tell you whether the JSON is even parsing.

Can I use manual schema alongside Yoast or Rank Math?

Only if you disable the plugin's schema output first. Otherwise you get two competing Article nodes on every page, which is worse than either alone.

Do I need to escape the output?

Yes. Always build a PHP array and pass it through wp_json_encode(). Never interpolate PHP variables into a hand-written JSON string, since a single apostrophe in a post title breaks the entire block.

All code on this page was tested on WordPress 7.0.2 / PHP 8.4.21 on 30 July 2026 and the resulting JSON-LD verified programmatically. Plugin byte comparisons from the same test bench.

Want to Confirm Your Markup Is Landing?

Use our Schema Gap Analyzer to audit structured data across your pages, or book a consultation for a clean implementation plan.

About the Author

Shakur Abdirahman
Technical SEO Specialist
Shakur is a Technical SEO Specialist with expertise in structured data implementation, large-scale website migrations, and redirect management. He tests schema output against real WordPress installs and helps businesses preserve search rankings and maintain crawl efficiency during complex site changes.

More about the author →