Working with Schemas in WordPress

Avatar of Pascal Klau (@pascalaoms)
Pascal Klau (@pascalaoms) on (Updated on )

I polled a group of WordPress developers about schemas the other day and was surprised by the results. Even though almost all of them had heard of schemas and were aware of the potential benefits they provide, very few of them were actually using them on a project.

If you’re unfamiliar with schemas, they are HTML attributes that help search engines understand the content structure and know-how to display it correctly in search engine results. We’ve all worked on projects where SEO was a big ol’ concern, so schemas can be a key deliverable to help optimize and deliver search performance.

We’re going to dig into the concept of schemas a little more in this post and then walk through a real-life application of how to use them in a WordPress environment.

A Schema Overview

Schemas are a vocabulary of HTML attributes and values that describe the content of the document. The concept of this vocabulary was born out of a collaboration between members of Google, Microsoft, Yahoo, and Yandex and has since become a project that is maintained by those founding organizations, in addition to members from the W3C and individuals in the community. In fact, you can view the Schema community’s activity and connect with the group on their open community page.

You may see the term structured data tossed around when schemas are being discussed and that’s because it’s a good description of how schemas work. They provide a lexicon and hierarchy in the form of data that add structure and detail to HTML markup. That, in turn, makes the content of an HTML document much easier for search engines to crawl, read, index, and interpret. If you see structured data somewhere, then we’re really talking about schemas as well.

The Schema Format

Schema can be served in three different formats: Microdata, JSON-LD, and RDFa. RDFa is one we aren’t going to delve into in this post because Microdata and JSON-LD make up the vast majority of use cases. In fact, as we dive into a working example later in this post, we’re going to shift our entire focus on JSON-LD.

Let’s illustrate the difference between Microdata and JSON-LD with an example of a business listing website, where visitors can browse information about local businesses. Each business is going to be an item that has additional context, such as a business type, a business name, a description, and hours of operation. We want our search engines to read that data for the sake of being able to render that information cleanly when returning search results. You know, something like this:

Walgreens Pharmacy uses schema to display an address, contact information, operating hours, and even additional site links.

Here’s how we would use Microdata to display business hours in a similar way:

<div itemscope="" itemtype="http://schema.org/Pharmacy">
  <h1 itemprop="name">Philippa's Pharmacy</h1>
  <p itemprop="description">
    A superb collection of fine pharmaceuticals.
  </p>
  <p>Open: 
    <span itemprop="openingHours" content="Mo,Tu,We,Th 09:00-12:00">
      Monday-Thursday 9am-noon
    </span>
  </p>
</div>

The same can be achieved via JSON-LD:

<script type="application/ld+json">
{
  "@context": "http://schema.org",
  "@type": "Pharmacy",
  "name": "Philippa's Pharmacy",
  "description": "A superb collection of fine pharmaceuticals.",
  "openingHours": "Mo,Tu,We,Th 09:00-12:00"
}
</script>

How Schema Impacts SEO

The reason we’re talking about schema at all is that we care about how our content is interpreted by search engines, so it’s fair to wonder just how much impact schema has on a site’s actual search engine ranking and performance.

Google’s John Mueller participated in a video chat back in 2015 and gave a very clear indication of how important schemas are becoming in the field of search engine optimization. The fact that the schema project was founded and is maintained by giants in the search engine industry gives us a good idea that, if we want to rank and index well, then we’ll consider schema as part of our SEO strategy.

While there may be other sites and posts out there that have better data to back up the importance of schema, the thing we ought to point to is the impact it has on user experience. If someone were to look up “Tom Petty Concert Tickets” in Google and get a list of results back, it’s easy to assume that the result with upcoming dates nicely outlined in the results would be the one that stands out the most and is most identifiably useful, even if it is not the first result in the bunch.

Oh nice, one of those results has schema that displays concert dates near me!

Again, this is conjecture and other posts or sites may have data to support the impact that schema has on search result rankings, but having a little bit of influence on the way search engines read and display our content on their pages is a nice affordance for us as front-end developers and we’ll take what we can get.

Deciding Which Format to Use

It really comes down to your flavor preference at the end of the day. That said, Google’s schema documentation is nearly all centered around JSON-LD so if you’re looking for more potential impact in Google’s results, that might be your starting point. Google even has a handy Webmasters tool that generates data in JSON-LD making it perhaps the lowest barrier to entry if you’re getting started.

Knowing What Data Can Be Structured

Google’s guide to structured data is the most exhaustive and comprehensive resource on the topic and gives the best indication of what data can be structured with examples of how to do it.

The bottom line is that schema wants to categorize content into “types” and these are the types that Google currently recognizes as of this writing:

  • Articles
  • Books
  • Courses
  • Datasets
  • Events
  • Fact Check
  • Job Postings
  • Local Businesses
  • Music
  • Podcasts
  • Products
  • Recipes
  • Reviews
  • TV & Movies
  • Videos

In addition to content type, Google will also look for structured data that serve as UI enhancements to the search results:

  • Breadcrumbs
  • Sitelinks Searchbox
  • Corporate Contact Information
  • Logos
  • Social Profile Links
  • Carousels

You can really start to see the opportunities we have to help influence search results as far as what is displayed and how it is displayed.

Managing Schema in WordPress

Alright, we’ve spent a good amount of time diving into the concept of schemas and how they can benefit a site’s search engine optimization, but how the heck do we work with it? I find the best way to tackle this is with a real-life example, so that’s what we’re going to do.

In this example, we’re using WordPress as our content management system and will put the popular Advanced Custom Fields (ACF) plugin to use. In the end, we will have a way to generate schema for our content on the fly using valid JSON-LD format.

Some readers may be tempted to stop me here and ask why we aren’t using the built-in schema management tools of popular WordPress SEO plugins, like Yoast and Schema. There are actually a ton of WordPress plugins that help add structured data to a site and going with any of them is a legitimate option that you ought to consider. In my experience, these plugins do not provide the level of detail I am looking for in projects that require access and control over every content type I need, such as opening hours and contact information for a local business.

That’s where ACF comes to my rescue! Not only can we create the exact fields we need to capture the data we want to generate and serve, but we can do it dynamically as part of our everyday content management in WordPress.

Let’s use a local business (spoiler alert on the Content-Type, am I right?!) website as an example. We’re going to create a custom page in WordPress that contains custom fields that allow us to manage the structured data for the business.

Here’s what that will look like:

I’ve put put all the working examples in this post together in a GitHub repo that you can use as a starting point or simply to follow along as we break down the steps to make it happen.

Download on GitHub

Step 1: Create the Custom Options Page

Setting up a custom admin page in WordPress can be done directly in our functions.php file:

// Create a General Options admin page
// `options_page` is going to be the name of ACF group we use to set up the fields
// We can use that as a conditional statement to create the page against
if (function_exists('acf_add_options_page')) {
  acf_add_options_page(array(
    'page_title' => 'General Options',
    'menu_title' => 'General Options',
    'menu_slug'  => 'general-options',
    'capability' => 'edit_posts',
    'redirect'   => false
  ));
}

That snippet gives us a new link in the WordPress navigation called General Options, but only after hooking things up in ACF in the next step. Of course, you can call this whatever you’d like. The point is that we now have a method for creating a page and a way to access it.

Step 2: Create the Custom Fields

Well, our General Options page is useless if there’s nothing in it. With Advanced Custom Fields installed and activated, we now need to head over there and set up the fields needed to capture and store our structured data.

Here is how our custom fields will be organized:

  • Company Logo
  • Company Address
  • Hours of Operation
  • Closed Days
  • Contact Information
  • Social Media Links
  • Schema Type

There are a lot of fields here and you can use the acf-export.json file from the GitHub repo to import the fields into ACF rather than manually creating them all yourself. Note that some of the fields use a repeater functionality that is only currently supported with a paid ACF extension.

Step 3: Linking Custom Fields to General Options

Now that we have the custom fields set up in ACF, our next task is to map them to our custom General Options page. Really, this step comes as the custom fields are bring created. ACF provides settings for each field group that allows you to specify whether the fields should be displayed on specific pages.

In other words, for each field group we’ve created, be sure to go back in and confirm that the General Options page is selected so that the fields only display there in WordPress:

Now our General Options page has an actual set of options we can manage!

Please Note:: The way the data is organized in the example files is how I’ve grown accustomed to managing scheme. You may find it easier to organize the fields in other ways, and that’s totally cool. And, of course, if you are working with a different content type than this local business example, then you may not need all of the fields we are working with here or be required to use others.

Step 4: Enter Data

Alright, without data, our structured data would just be … um, structured? Whatever that would be, let’s enter the data.

  • Company Logo: Google specifies the ideal size to be 151px square. Google will use this image if it displays company information to the right of the search results. You can see this in action by searching a well-known company, like Google itself.
  • Building Photo: This can add some interest to the same company profile card where the Company Logo is displayed, but this field also impacts search results within maps. Google recommends a square 200px image.
  • Schema Type: Select the content type for the schema. In this example, we are dealing with a local business, so that is the content type.
  • Address: These are pretty straight-forward text fields and will be used both in search results and the same profile card as the Company Logo.
  • Openings: The specification for opening hours can be found on the schema.org website. The way we’ve set this up in the example is by using a repeater field that contains four sub-fields to specify the days of the week, the starting open time, the ending open time, and a toggle to distinguish between open and closed time ranges. This should cover all our bases, according to the schema documentation.
  • Special Days: These are holidays (e.g. Christmas) where the business might not be open during its regular operating hours. It’s nice that schema provides this flexibility because it allows users to see those exceptions if they happen to be searching on those days.
  • Contact: There are a lot of settings available for contact data. We are putting three of them use here with this example, namely Type (which is used like a business Department, say, Sales or Customer Service), Phone (which is the number to call), and Option (which supports options for TollFree and HearingImpairedSupported

Step 5: Generate the the JSON-LD

This is where the rubber meets the road. If so far we have created a place to manage our data, made the fields for that data, and actually entered the data, then we now need to take that collected data and spit it out into a format that search engines can put to use. Again, the GitHub repo has the finished result of what we’re dealing with, but let’s dig into that code to see how that data is fetched from ACF and converted to JSON-LD.

To read all the values and create the JSON-LD tag, we need to go into the functions.php file and write a snippet that injects our JSON data to the site header. We’re going to inject the content type, address, and some data about the site that already exists in WordPress, such as the site name and address:

// Using `wp_head` to inject to the document <head>
add_action('wp_head', function() {
  $schema = array(
    // Tell search engines that this is structured data
    '@context'  => "http://schema.org",
    // Tell search engines the content type it is looking at 
    '@type'     => get_field('schema_type', 'options'),
    // Provide search engines with the site name and address 
    'name'      => get_bloginfo('name'),
    'url'       => get_home_url(),
    // Provide the company address
    'telephone' => '+49' . get_field('company_phone', 'options'), //needs country code
    'address'   => array(
      '@type'           => 'PostalAddress',
      'streetAddress'   => get_field('address_street', 'option'),
      'postalCode'      => get_field('address_postal', 'option'),
      'addressLocality' => get_field('address_locality', 'option'),
      'addressRegion'   => get_field('address_region', 'option'),
      'addressCountry'  => get_field('address_country', 'option')
    )
  );
}

The logo is not really a required bit of information, we we’re going to check whether it exists, then fetch it if it does and add it to the mix:

// If there is a company logo...
if (get_field('company_logo', 'option')) {
  // ...then add it to the schema array
  $schema['logo'] = get_field('company_logo', 'option');
}

Working with repeater fields in ACF requires a little extra consideration, so we’re going to have to write a loop to fetch and add the social media links:

// Check for social media links
if (have_rows('social_media', 'option')) {
  $schema['sameAs'] = array();
  // For each instance...
  while (have_rows('social_media', 'option')) : the_row();
    // ...add it to the schema array
    array_push($schema['sameAs'], get_sub_field('url'));
  endwhile;
}

Adding the data from the Opening Hours fields is a little tricky, but only because we added that additional differentiation between open and closed time ranges. Basically, we need to check for the $closed variable we set up as part of the field then output the times so they fall in right group.

// Let's check for Opening Hours rows
if (have_rows('opening_hours', 'option')) {
  // Then set up the array
  $schema['openingHoursSpecification'] = array();
  // For each row...
  while (have_rows('opening_hours', 'option')) : the_row();
    // ...check if it's marked "Closed"...
    $closed = get_sub_field('closed');
    // ...then output the times
    $openings = array(
      '@type'     => 'OpeningHoursSpecification',
      'dayOfWeek' => get_sub_field('days'),
      'opens'     => $closed ? '00:00' : get_sub_field('from'),
      'closes'    => $closed ? '00:00' : get_sub_field('to')
    );
    // Finally, push this array to the schema array
    array_push($schema['openingHoursSpecification'], $openings);

  endwhile;
}

We can use almost the same snippet to output our Special Days data:

// Let's check for Special Days rows
if (have_rows('special_days', 'option')) {
  // For each row...
  while (have_rows('special_days', 'option')) : the_row();
    // ...check if it's marked "Closed"...
    $closed = get_sub_field('closed');
    // ...then output the times
    $special_days = array(
      '@type'        => 'OpeningHoursSpecification',
      'validFrom'    => get_sub_field('date_from'),
      'validThrough' => get_sub_field('date_to'),
      'opens'        => $closed ? '00:00' : get_sub_field('time_from'),
      'closes'       => $closed ? '00:00' : get_sub_field('time_to')
    );
    // Finally, push this array to the schema array
    array_push($schema['openingHoursSpecification'], $special_days);

  endwhile;
}

The last piece is our Contact Information data. Again, we’re working with a loop that creates and array that then gets injected into the schema array which, in turn gets injected into the document .

Notice that the phone number needs the country code, which you can swap out for your own:

// Let's check for Contact Information rows
if (get_field('contact', 'options')) {
  // Then create an array of the data, if it exists
  $schema['contactPoint'] = array();
  // For each row of contact information...
  while (have_rows('contact', 'options')) : the_row();
    // ...fetch the following fields
    $contacts = array(
      '@type'       => 'ContactPoint',
      'contactType' => get_sub_field('type'),
      'telephone'   => '+49' . get_sub_field('phone')
    );
    // Let's not forget the Option field
    if (get_sub_field('option')) {
       $contacts['contactOption'] = get_sub_field('option');
    }
   // Finally, push this array to the schema array
   array_push($schema['contactPoint'], $contacts);

  endwhile;
}

Let’s Marvel at Out Work!

We now can encode our data in JSON and put it into a script tag right before the closing of our add_action function.

echo '<script type="application/ld+json">' . json_encode($schema) . '</script>';

Reader Mark Gombar suggests json_encode($schema, JSON_UNESCAPED_SLASHES) to avoid escaped slashes in URLs.

The final script might look something like this:

<script type="application/ld+json">
{
    "@context": "http://schema.org",
    "@type": "Store",
    "name": "My Store",
    "url": "https://my-domain.com",
    "telephone": "+49 1234 567",
    "address": {
        "@type": "PostalAddress",
        "streetAddress": "Musterstraße",
        "postalCode": "13123",
        "addressLocality": "Berlin",
        "addressRegion": "Berlin",
        "addressCountry": "Deutschland"
    },
    "sameAs": ["https://facebook.com/my-profile"],
    "openingHoursSpecification": [{
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": ["Mo", "Tu", "We", "Th", "Fr"],
        "opens": "07:00",
        "closes": "20:00"
    }, {
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": ["Sa", "Su"],
        "opens": "00:00",
        "closes": "00:00"
    }, {
        "@type": "OpeningHoursSpecification",
        "validFrom": "2017-08-12",
        "validThrough": "2017-08-12",
        "opens": "10:00",
        "closes": "12:00"
    }],
    "contactPoint": [{
        "@type": "ContactPoint",
        "contactType": "customer support",
        "telephone": "+491527381923",
        "contactOption": ["HearingImpairedSupported"]
    }]
}
</script>

Conclusion

Hey, look at that! Now we can enhance a website’s search engine presence with optimized data that allows search engines to crawl and interpret information in an organized way that promotes a better user experience.

Of course, this example was primarily focused on JSON-LD, Google’s schema specifications, and using WordPress as a vehicle for managing and generating data. If you have written up ways of managing and handling data on other formats, using different specs and other content management systems, please share it here in the comments and we can start to get a bigger picture for improving SEO all around.