14+ years building on WordPress / Replies in under 5 hours
Development 17 min read · Updated September 2026

How to Add Schema Markup in WordPress Without a Plugin (With Code Snippets)

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
How to Add Schema Markup in WordPress Without a Plugin
TL;DR

To add schema markup in WordPress without a plugin, hook a function to wp_head, build the structured data as a PHP array, and output it with wp_json_encode() using the JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE and JSON_HEX_TAG flags. Keep the output conditional so each schema type only fires on the pages it actually describes, then validate the live URL with Google’s Rich Results Test.

You can add schema markup in WordPress in about fifteen lines of PHP, no plugin required. The part nobody mentions is that writing the JSON-LD is the easy half. The hard half is deciding what to mark up, keeping it honest against what’s actually on the page, and not duplicating whatever Rank Math or Yoast already outputs.

This guide covers the code, the schema types still worth your time in 2026, and the reasons markup can validate perfectly and still do nothing for you. Every snippet here follows the pattern I run in production, not a copy-paste from the schema.org examples page.

What Schema Markup Actually Does in WordPress

Schema markup is structured data you add to a page so search engines and AI systems can read it as facts instead of inferring them from prose. It doesn’t change what a visitor sees. It changes what a machine can state with confidence: this is an article, published on this date, written by this person, about this thing.

WordPress ships with none of this built in. Your theme outputs HTML; structured data is a separate layer you either add yourself or delegate to a plugin. The format worth using is JSON-LD, a block of JSON in a script tag. Google recommends it, it sits apart from your markup, and it doesn’t force you to wrap HTML elements in extra attributes the way microdata does.

What it won’t do: schema markup is not a ranking factor on its own. Adding Article markup to a thin post does not make it rank. What it does is make you eligible for rich results, and make your content cleaner to parse for the AI answer engines that increasingly sit between your site and its readers. That second reason has quietly become the better one.

Plugin or Custom Code: Which One You Actually Need

Before writing a line of PHP, be honest about whether you need to. If Rank Math or Yoast is already emitting correct Article and BreadcrumbList markup for your posts, hand-rolling your own duplicates it and creates a conflict you’ll have to debug later.

SEO plugin Custom code
Setup time Minutes An hour or more per type
Article, Breadcrumb, Organization Automatic You write all of it
Custom post types and bespoke fields Limited, often a paid add-on Full control
Control over @id and graph shape Little to none Complete
Survives a plugin update Usually, not always Only breaks if you break it
Best for Most sites, most of the time CPTs, precise entity modelling, filling plugin gaps

Custom code earns its place in three situations: you have custom post types the plugin doesn’t understand, you need fields the plugin won’t expose, or you want deliberate control over how entities link to each other. Outside those, use the plugin and spend the afternoon on something that moves revenue.

How to Add Schema Markup in WordPress Without a Plugin

Here’s the whole method in six steps. The example below outputs BlogPosting markup on single posts, and every later schema type in this guide reuses the same skeleton.

Step 1: Put the Code Where an Update Won’t Erase It

Never edit a parent theme’s functions.php directly. The next theme update overwrites it and your schema disappears without warning. You have two safe homes: a child theme’s functions.php, or a small site-specific plugin.

I prefer the site-specific plugin, because structured data describes your business rather than your design. Switch themes in two years and the markup should survive that. Create wp-content/plugins/mysite-schema/mysite-schema.php:

<?php
/**
 * Plugin Name: MySite Schema
 * Description: Custom JSON-LD structured data.
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

That ABSPATH guard stops the file executing if someone requests it directly. Activate the plugin in wp-admin and everything below goes in this file.

Step 2: Hook Your Function to wp_head

Schema belongs in the document head, so hook to wp_head. Use a late priority so your markup lands after the plugins that might also be writing there, which makes debugging the page source far easier.

function mysite_output_schema() {
    // Schema goes here.
}
add_action( 'wp_head', 'mysite_output_schema', 20 );

Technically JSON-LD is valid anywhere in the document, and Google reads it in the body too. Keeping it in the head is a convention worth following anyway, because that’s where every other developer will look for it.

Step 3: Build the Schema as a PHP Array, Never a String

This is the single decision that separates markup that works from markup that breaks in production. Build a PHP associative array and let WordPress serialise it. Do not concatenate a JSON string by hand.

$schema = array(
    '@context' => 'https://schema.org',
    '@type'    => 'BlogPosting',
    'headline' => get_the_title(),
    'url'      => get_permalink(),
);

Hand-built JSON strings fail the first time a post title contains an apostrophe, a quotation mark, or an em dash. I have debugged this exact bug on client sites more times than I’d like to admit, and it always looks like “schema randomly stopped working on some posts.”

Step 4: Encode It with wp_json_encode() and the Right Flags

Use wp_json_encode() rather than PHP’s raw json_encode(). WordPress’s wrapper handles invalid UTF-8 gracefully instead of silently returning false and leaving you with an empty script tag.

echo '<script type="application/ld+json">'
    . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
    . '</script>';

Those three flags each earn their place. JSON_UNESCAPED_SLASHES keeps URLs readable instead of littering them with backslashes. JSON_UNESCAPED_UNICODE stops accented characters and non-Latin scripts turning into escape sequences.

JSON_HEX_TAG is the one almost every tutorial omits, and it’s a security control, not a cosmetic choice. It encodes angle brackets as \u003C. Without it, any content containing a literal closing script tag terminates your JSON-LD block early and injects whatever follows into the page. If you want the fuller picture on why escaping output is non-negotiable, I wrote about sanitisation versus validation in WordPress separately.

Step 5: Make the Output Conditional

Unconditional schema is wrong schema. BlogPosting markup on your contact page tells Google something false about that page, and false structured data is worse than none.

function mysite_output_schema() {
    if ( ! is_singular( 'post' ) ) {
        return;
    }

    $schema = array(
        '@context' => 'https://schema.org',
        '@type'    => 'BlogPosting',
        'headline' => wp_strip_all_tags( get_the_title() ),
        'url'      => get_permalink(),
    );

    echo '<script type="application/ld+json">'
        . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
        . '</script>';
}
add_action( 'wp_head', 'mysite_output_schema', 20 );

Bail early with a guard clause. is_singular( 'post' ), is_page( 'about' ), is_front_page() and is_post_type_archive() cover most of what you’ll need, and they read clearly six months later.

Step 6: Validate Before You Move On

Load the page, view source, and confirm the script tag is there with the values you expect. Then run the URL through Google’s Rich Results Test. Never assume it worked because the code looks right.

BlogPosting and Article Schema in WordPress

Article and its more specific children, BlogPosting and NewsArticle, are the types most WordPress sites need. Use BlogPosting for blog content and Article when you genuinely aren’t sure. Google treats them near-identically.

Here’s the complete version, with author, publisher, dates and featured image wired to real WordPress data:

function mysite_blogposting_schema() {
    if ( ! is_singular( 'post' ) ) {
        return;
    }

    $post_id   = get_the_ID();
    $author_id = (int) get_post_field( 'post_author', $post_id );
    $permalink = get_permalink( $post_id );

    $schema = array(
        '@context'         => 'https://schema.org',
        '@type'            => 'BlogPosting',
        '@id'              => $permalink . '#article',
        'headline'         => wp_strip_all_tags( get_the_title( $post_id ) ),
        'description'      => wp_strip_all_tags( get_the_excerpt( $post_id ) ),
        'datePublished'    => get_the_date( 'c', $post_id ),
        'dateModified'     => get_the_modified_date( 'c', $post_id ),
        'mainEntityOfPage' => array(
            '@type' => 'WebPage',
            '@id'   => $permalink,
        ),
        'author'           => array(
            '@type' => 'Person',
            'name'  => get_the_author_meta( 'display_name', $author_id ),
            'url'   => get_author_posts_url( $author_id ),
        ),
        'publisher'        => array(
            '@type' => 'Organization',
            'name'  => get_bloginfo( 'name' ),
            'url'   => home_url( '/' ),
        ),
    );

    if ( has_post_thumbnail( $post_id ) ) {
        $schema['image'] = get_the_post_thumbnail_url( $post_id, 'full' );
    }

    echo '<script type="application/ld+json">'
        . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
        . '</script>';
}
add_action( 'wp_head', 'mysite_blogposting_schema', 20 );

Three details worth noting. get_the_date( 'c' ) returns ISO 8601, which is the only date format Google accepts. The @id gives this node a stable identifier so other nodes can reference it later. And has_post_thumbnail() guards the image key, because an image property pointing at nothing is an error rather than an omission.

FAQ Schema in WordPress: What Changed, and Whether It’s Still Worth It

Here’s the part most 2026 tutorials still get wrong. In August 2023 Google narrowed FAQ rich results to well-known government and health sites. If you run a business blog, your FAQPage markup will almost certainly never produce those expandable questions in search results again.

So should you still add it? I do, for a narrower reason: answer engines and AI search parse FAQPage markup happily, and it’s the cleanest way to hand a machine a set of question-and-answer pairs. Just don’t add it expecting a visual rich result, and don’t let anyone sell you an FAQ plugin on that promise.

The critical rule: every question and answer in your markup must be visible on the page. Marking up FAQs that only exist in the JSON is a structured data violation and can earn a manual action.

function mysite_faq_schema( array $faqs ) {
    $entities = array();

    foreach ( $faqs as $faq ) {
        $question = isset( $faq['question'] ) ? trim( $faq['question'] ) : '';
        $answer   = isset( $faq['answer'] ) ? trim( $faq['answer'] ) : '';

        if ( ! $question || ! $answer ) {
            continue;
        }

        $entities[] = array(
            '@type'          => 'Question',
            'name'           => $question,
            'acceptedAnswer' => array(
                '@type' => 'Answer',
                'text'  => $answer,
            ),
        );
    }

    if ( ! $entities ) {
        return null;
    }

    return array(
        '@context'   => 'https://schema.org',
        '@type'      => 'FAQPage',
        'mainEntity' => $entities,
    );
}

Notice it returns null rather than an empty FAQPage when there’s nothing to output. Empty container nodes are a common source of validation warnings, and the fix is always this same guard.

Feed it whatever your site stores FAQs in, typically an ACF repeater:

$faq_schema = mysite_faq_schema( get_field( 'faqs' ) ?: array() );

if ( $faq_schema ) {
    echo '<script type="application/ld+json">'
        . wp_json_encode( $faq_schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
        . '</script>';
}

HowTo Schema in WordPress

Same story, shorter. Google deprecated HowTo rich results in that same 2023 round, so the step-by-step carousel is gone for ordinary sites. The markup still describes a procedure accurately for anything else reading the page.

If you add it, add one guard most implementations miss: a HowTo with a single step isn’t a sequence, and claiming otherwise misrepresents the content.

function mysite_howto_schema( array $steps, $name ) {
    if ( ! $name ) {
        return null;
    }

    $nodes = array();

    foreach ( $steps as $step ) {
        $step_name = isset( $step['step_name'] ) ? trim( $step['step_name'] ) : '';
        $step_text = isset( $step['step_text'] ) ? trim( $step['step_text'] ) : '';

        if ( ! $step_name || ! $step_text ) {
            continue;
        }

        $nodes[] = array(
            '@type'    => 'HowToStep',
            'position' => count( $nodes ) + 1,
            'name'     => $step_name,
            'text'     => $step_text,
        );
    }

    // One step is not a procedure.
    if ( count( $nodes ) < 2 ) {
        return null;
    }

    return array(
        '@context' => 'https://schema.org',
        '@type'    => 'HowTo',
        'name'     => $name,
        'step'     => $nodes,
    );
}

As with FAQs, the steps in your markup must restate steps that appear in the visible content. Structured data describes the page. It never adds to it.

LocalBusiness Schema in WordPress

If you serve customers from a physical location or a defined service area, LocalBusiness is the highest-value markup on this list, because it still drives visible results in local search rather than just feeding parsers.

Output it once, on the front page, and give it a stable @id so other pages can point at the same entity:

function mysite_localbusiness_schema() {
    if ( ! is_front_page() ) {
        return;
    }

    $schema = array(
        '@context'    => 'https://schema.org',
        '@type'       => 'LocalBusiness',
        '@id'         => home_url( '/#business' ),
        'name'        => get_bloginfo( 'name' ),
        'url'         => home_url( '/' ),
        'telephone'   => '+1-555-0100',
        'email'       => 'hello@example.com',
        'priceRange'  => '$$',
        'address'     => array(
            '@type'           => 'PostalAddress',
            'streetAddress'   => '123 Example Street',
            'addressLocality' => 'Jaipur',
            'addressRegion'   => 'Rajasthan',
            'postalCode'      => '302001',
            'addressCountry'  => 'IN',
        ),
        'openingHoursSpecification' => array(
            array(
                '@type'     => 'OpeningHoursSpecification',
                'dayOfWeek' => array( 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday' ),
                'opens'     => '09:00',
                'closes'    => '18:00',
            ),
        ),
    );

    echo '<script type="application/ld+json">'
        . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
        . '</script>';
}
add_action( 'wp_head', 'mysite_localbusiness_schema', 20 );

Swap LocalBusiness for a more specific subtype where one fits, such as Dentist, Restaurant or ProfessionalService. More specific is better, as long as it’s accurate. And the name, address and phone here must match your Google Business Profile and the details in your site footer exactly, character for character. Inconsistent contact details across those three places is one of the most common local SEO problems I get called in to untangle.

Product Schema for WooCommerce: Extend, Don’t Duplicate

WooCommerce already outputs Product schema with price, availability and reviews. Writing your own from scratch gives you two competing Product nodes and a conflict Google resolves however it likes.

Filter the existing markup instead. WooCommerce exposes it directly:

add_filter( 'woocommerce_structured_data_product', 'mysite_extend_product_schema', 10, 2 );

function mysite_extend_product_schema( $markup, $product ) {
    $brand = get_post_meta( $product->get_id(), '_product_brand', true );

    if ( $brand ) {
        $markup['brand'] = array(
            '@type' => 'Brand',
            'name'  => $brand,
        );
    }

    $gtin = get_post_meta( $product->get_id(), '_gtin', true );

    if ( $gtin ) {
        $markup['gtin13'] = $gtin;
    }

    return $markup;
}

This is the pattern for any plugin that already emits schema: find its filter and add to what it produces. Adding a second node is almost always the wrong move.

Author Schema: Linking a Person to Your Content

Author markup matters more than it used to, because both search engines and AI systems are leaning on author identity as a trust signal. The goal is a single Person entity with a stable @id that your articles reference, instead of a fresh anonymous author object inlined on every post.

function mysite_person_node() {
    return array(
        '@type'      => 'Person',
        '@id'        => home_url( '/#person' ),
        'name'       => 'Jane Doe',
        'url'        => home_url( '/about/' ),
        'jobTitle'   => 'WordPress Developer',
        'sameAs'     => array(
            'https://www.linkedin.com/in/example/',
            'https://github.com/example/',
        ),
    );
}

Then reference it from the article node rather than repeating the details:

{
  "@type": "BlogPosting",
  "headline": "…",
  "author": { "@id": "https://example.com/#person" }
}

That sameAs array is doing real work. It connects your author to profiles that already exist elsewhere, which is how a search engine ties the name on your blog to a known entity rather than treating it as an unfamiliar string.

Adding Schema to Custom Post Types

This is where custom code genuinely beats plugins. Most SEO plugins treat a custom post type as a generic page and emit nothing useful. If you’ve registered custom post types for case studies, events or properties, you can map their fields to the right schema type directly.

function mysite_cpt_schema() {
    if ( ! is_singular( 'case_study' ) ) {
        return;
    }

    $schema = array(
        '@context'    => 'https://schema.org',
        '@type'       => 'CreativeWork',
        '@id'         => get_permalink() . '#work',
        'name'        => wp_strip_all_tags( get_the_title() ),
        'description' => wp_strip_all_tags( get_the_excerpt() ),
        'url'         => get_permalink(),
        'about'       => get_post_meta( get_the_ID(), 'industry', true ),
    );

    echo '<script type="application/ld+json">'
        . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG )
        . '</script>';
}
add_action( 'wp_head', 'mysite_cpt_schema', 20 );

Pick the closest type schema.org actually defines. Event, JobPosting, Course, RealEstateListing and Recipe all have documented required fields, and matching them properly is what makes a CPT eligible for rich results at all.

When You End Up With Multiple Schema Blocks

Add markup type by type and you’ll eventually have four or five separate script tags on one page. Google parses that fine. What it can’t do is infer that your Organization, your Person and your BlogPosting are related to each other.

The fix is @graph, which puts every node in one array and links them by @id. I’ve covered that in detail in how to merge multiple JSON-LD schema scripts, including the @id conventions and the mistakes that quietly break entity linking. If you’re adding more than two schema types, read that next.

How to Test and Validate WordPress Schema Markup

Three tools, and they answer different questions. Use all three rather than picking a favourite.

  • Rich Results Test tells you whether Google can read your markup and whether it qualifies for a rich result. It only reports on types Google supports, so valid schema it doesn’t use simply won’t appear.
  • Schema Markup Validator checks your JSON-LD against the full schema.org vocabulary. This is the one to reach for when Rich Results Test shows nothing and you need to know whether the markup itself is wrong or just unsupported.
  • Search Console reports what Google actually found on your live pages over time, under the Enhancements section. It’s the only one of the three reflecting real crawls rather than a single on-demand fetch.

Test the rendered URL, not pasted code. Pasting the JSON validates the JSON. It doesn’t prove your conditional fired, your caching layer served the current HTML, or the page outputs the markup for a logged-out visitor.

Schema Markup Not Showing? Work Through These

When markup doesn’t appear, it’s almost always one of these, roughly in the order I check them:

  • A caching layer is serving old HTML. Purge page cache and any CDN cache, then check again in a private window. This accounts for more “broken schema” reports than every other cause combined.
  • The conditional never matched. Add a temporary error_log( 'schema fired' ); inside the function and watch the log while loading the page. If nothing logs, your is_singular() check is wrong, not your JSON.
  • The hook never ran. Themes that don’t call wp_head() silently drop everything hooked to it. Rare in commercial themes, common in hand-built ones.
  • Invalid JSON from string concatenation. If you ignored step three, an apostrophe in a title is enough to break the block. wp_json_encode() fixes this permanently.
  • The markup describes content that isn’t on the page. Valid JSON, ignored by Google, and a possible manual action.
  • It’s working and you’re impatient. Rich results need a recrawl. Days, sometimes longer. Request indexing in Search Console rather than rewriting code that’s already correct.

Structured data errors in Search Console are worth reading closely, because Google distinguishes errors from warnings. Errors make a page ineligible for the rich result. Warnings mean a recommended field is missing and it’ll usually still work.

What I’d Actually Do

If you’re running a straightforward blog or business site, install Rank Math, let it handle Article, Breadcrumb and Organization, and write custom code only for what it misses. If you’ve got custom post types, an unusual content model, or you care about how your entities link together, write it yourself with the pattern above and keep it in a site-specific plugin.

Either way, validate against the live URL and check Search Console a week later. Markup you never verified is markup you don’t actually have.

If your structured data is throwing errors you can’t trace, or a previous developer left schema scattered across three plugins and a theme file, that’s the kind of untangling I do.

Frequently asked questions

Yes. Hook a function to wp_head, build the structured data as a PHP array, and echo it inside a script tag using wp_json_encode(). Put the code in a child theme’s functions.php or a small site-specific plugin so a theme update can’t wipe it.

In a site-specific plugin, or your child theme’s functions.php. I prefer a plugin, because structured data describes your business rather than your design, so it should survive a theme change. Never edit the parent theme’s functions.php directly.

Usually a caching layer serving old HTML, a conditional that never matched, or invalid JSON from string concatenation. Check the rendered page source first in a private window. If the markup is there and valid, it may simply be waiting on a recrawl.

Run the live URL through Google’s Rich Results Test for rich-result eligibility, and the schema.org validator for full vocabulary checking. Then watch Search Console’s Enhancements reports, which show what Google found on real crawls over time.

Not for rich results on most sites. Google narrowed FAQ rich results to well-known government and health sites in 2023. It is still worth adding as clean question-and-answer data for AI answer engines, provided every question and answer is visible on the page.

It can. If your plugin already outputs Article or Product markup, adding your own creates duplicate nodes that Google resolves unpredictably. Filter the plugin’s existing output instead, and reserve custom code for types it does not handle.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →