I hand-code schema markup for most client sites, and the reason is not stubbornness — it is that a plugin-generated block is a black box you cannot fully control, and once you have written a few dozen JSON-LD templates it takes less time to write the code directly than to fight a plugin’s settings panel into producing the exact output you need.
This is the path I use when a client owns their theme files, has a developer on staff, or just wants one less plugin adding weight and update risk to the site. It is not the right call for every situation — if you are on a page builder with no template access, skip ahead to a Custom HTML block or Google Tag Manager instead. But if you can touch the theme, here is the actual process.
Decide Where the Markup Lives
You have three practical options on a WordPress site: a function hooked to wp_head, a partial template file included in your header, or a Custom HTML block dropped directly into the page content. I default to the wp_head hook for anything that needs to run sitewide — Organization schema, WebSite schema with a sitelinks search box — and I use per-template functions for anything that varies by content type, like Article schema on blog posts or Product schema on a WooCommerce product page.
The logic: sitewide schema belongs in one place so you only ever update it in one place. Content-type schema belongs wherever that content type’s template already lives, because you want the markup to pull from the same variables the visible page uses — the same title, the same date, the same author. Duplicating that data by hand in a second location is exactly how markup and page content drift apart over time.
A Minimal Sitewide Example
Here is the pattern I start most builds with — Organization schema that fires on every page via functions.php:
add_action( 'wp_head', 'salterra_org_schema' );
function salterra_org_schema() {
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'Organization',
'@id' => home_url( '/#organization' ),
'name' => get_bloginfo( 'name' ),
'url' => home_url( '/' ),
'logo' => get_site_icon_url(),
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>';
}
Notice I am pulling get_bloginfo() and home_url() rather than hardcoding the site name and URL as strings. That is the entire point of doing this in code instead of a static block: the schema updates itself if the site name changes, and it works identically across staging and production without editing anything.
Content-Type Schema Inside a Template
For markup that varies per post — Article schema on a blog template, for example — I write a function that runs inside the loop and pulls from the post object directly:
function salterra_article_schema( $post_id ) {
$schema = array(
'@context' => 'https://schema.org',
'@type' => '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 ) ),
),
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>';
}
add_action( 'wp_head', function() {
if ( is_single() ) {
salterra_article_schema( get_the_ID() );
}
});
The wp_json_encode() function matters more than it looks like it should. It handles quoting, escaping, and Unicode correctly, and it is available in every WordPress install without an extra dependency. I have seen enough hand-built JSON strings break on an apostrophe in a title to never build the object as a raw string again.
Handling Multiple Types on the Same Template
If a single page needs more than one schema type, resist the urge to echo two separate script tags just because it is easier to write. Build one PHP array with a top-level @graph key, push each type into it as you go, and echo the whole thing once at the end of wp_head. This is the same pattern I cover in the main schema markup guide, and the reasoning carries over directly: one block is easier to audit, and referencing entities by @id instead of repeating them keeps the whole thing internally consistent.
What You Give Up Without a Plugin
Be honest with yourself about the tradeoff. A plugin like Rank Math will generate reasonable defaults for content types you have not thought about yet — FAQ blocks, review snippets, recipe cards — without you writing a line of code. Hand-coding means you are responsible for every type you want covered, and nothing appears until you build it. For a site with five templates and a developer who touches the code monthly, that is a fair trade for the control it buys. For a site with no developer and fifteen content types, it usually is not, and a plugin is the more realistic choice.
Validate Before You Ship
Whatever you build by hand, run it through Google’s Rich Results Test and the Schema.org validator before considering it done — the same validation step applies whether the markup came from a plugin or from your own function, and skipping it on hand-coded schema is how a typo in a property name goes unnoticed for months.
Debugging Hand-Coded Schema That Won’t Validate
When a hand-built block fails validation, the cause is almost always one of a handful of things, in roughly this order of frequency. First, an unescaped quote or apostrophe inside a string value, which breaks the JSON structure entirely — this is exactly why I use wp_json_encode() rather than concatenating a string by hand, since PHP’s own encoder handles escaping correctly every time. Second, a trailing comma after the last item in an array or object, which is valid in some languages but not in JSON. Third, a value with the wrong type — a number wrapped in quotes as a string, or a single object where the schema expects an array. Paste the output into a plain JSON linter before you even get to the Rich Results Test; it catches structural errors faster than either validator and tells you exactly which line is broken.
Keeping Hand-Coded Schema in Version Control
One advantage of the code-based approach that is easy to overlook: your schema now lives in your theme’s version control alongside everything else, instead of in a plugin’s database settings that nobody diffs or reviews. When a developer changes an Article schema function, that change shows up in a pull request like any other code change, and you have a record of exactly what changed and when. That audit trail has saved me real time on client sites where a schema regression needed to be traced back to a specific deploy rather than guessed at from a vague “it used to work” report.