4 min read

Taking Control of Structured Data in Ghost

Notes on setting up structured data a Ghost theme, including how to handle static and dynamic data.
Taking Control of Structured Data in Ghost
Photo by Tandem X Visuals / Unsplash
⚠️ This is written from the perspective of Ghost theme development. If you're an author/end-user, this information likely won't be helpful. Also, I'm pretty new to Ghost theme development, so it's quite possible I don't have the full picture. YMMV.

I wanted full structured data coverage for HanaYou's website. Ghost already emits WebSite and Article, but I also needed LocalBusiness, FAQ, BreadcrumbList and so on so search engines and LLMs get a complete picture.

That sent me into the theme layer to see how much control I could take over JSON-LD without turning maintenance into a chore.

Static Data: So Far So Good

I started with static data. In my theme repo, I created a central data/schema.json that holds information that rarely changes: organization details, Kyoto studio info, geo fields, price range, FAQ copy, and so on.

I wrote a small build script, scripts/build-schema.mjs, that reads this file and writes JSON-LD partials that templates can then pick up during the Ghost build process. I provide more detail on this workflow below.

In short, this worked well. Static data does not change between builds, and build time is a safe place to serialize JSON. I can update the data itself in one JSON file, not scattered across templates.

Dynamic Data: Where It Fell Apart

With the static layer working, I moved to posts and pages. Bear in mind that post and page data can be created and changed at runtime. As such, I'll refer to this as dynamic data from here on out.

By default, {{ghost_head}} emits WebSite and Article schema. I wanted to try replacing that schema for full centralized control.

Ghost recently added an exclude parameter to {{ghost_head}}. The exclude parameter allows selective opt-out of elements in the {{ghost_head}} like schema.

🙏 Thanks to Cathy Sarisky’s write-up and her response to me on the Ghost developer forum for surfacing the exclude param).

I tried:

{{ghost_head exclude="schema"}}
{{> custom-schema}}

Then I generated my own JSON-LD for WebSite, BlogPosting (for posts), and Article (for pages).

This is where things got problematic. The theme layer does not provide a JSON-safe escape helper. Without JSON-safe strings, any quotes, backslashes, or newlines in titles or descriptions would corrupt the JSON-LD.

Full control is possible in theory, but fragile in practice.

What I Landed On

For dynamic data, I kept {{ghost_head}} intact and let Ghost continue emitting WebSite and Article.

I layer the static structured data in at build time using partials. My static partials cover:

  • Organization
  • LocalBusiness
  • FAQ
  • BreadcrumbList

How it works for static data:

  1. All data destined for static JSON-LD is stored in data/schema.json within the theme repo
  2. When I run the build process (npm run build), the first thing that runs is a custom script called scripts/build-schema.mjs
  3. The custom script parses through the JSON file and generates partials in partials/schema/ containing the desired JSON-LD
  4. During the Ghost build process, templates include the partials declaratively

To be a bit more concrete, here's an example of a generated JSON-LD partial (partials/schema/breadcrumbs-home.hbs) from step 3 above:

{{! Auto-generated from data/schema.json -- do not edit manually }}
{{#contentFor "head"}}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "{{@site.url}}/"
    }
  ]
}
</script>
{{/contentFor}}

In turn, index.hbs includes this partial at build time:

{{!< default}}

{{> "schema/breadcrumbs-home"}}

<main ...

When rendered in the browser, the head on the home page now includes this JSON-LD:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": "http://www.hanayou.com/"
    }
  ]
}
</script>

This sort of thing is repeated for all static structured data that I want to include in the site.

Why This Works Better

It's a slight compromise, but one I'm happy to make instead of shoehorning my druthers into the Ghost framework via a custom theme.

  • Dynamic content stays safe. Ghost handles JSON serialization and updates automatically. While I'd prefer to do this myself, it seems there's not a great way to do it at the theme layer today.
  • Static content stays maintainable. I edit a single JSON file and rebuild.
  • No duplication. Ghost covers WebSite and Article, and I add only what is missing.
  • Easy to extend. Adding another location or schema type starts with the data file, not the templates.

If Ghost exposes a JSON escape helper or a richer schema API in the future, I can revisit full control with {{ghost_head exclude="schema"}} . In the interim I have avoided both generating potentially malformed JSON and building a brittle workaround.

Takeaways

Just a few takeaways to sum it up:

  1. For Ghost theme developers, adding structured data is relatively straightforward for static entities.
  2. Asserting control over dynamic JSON-LD isn't practical today. It's better just to let Ghost emit it.
  3. If your end users want control over this data (and it would be very reasonable for them to want this), you'll have to consider how to make that possible. For my current use case, end-user control isn't required.

For now, the hybrid model is the stable path. Let Ghost handle dynamic schema. Add static schema at build time. It's not absolute control, but it is reliable, maintainable, and easy to validate.