# Taking Control of Structured Data in Ghost

[Canonical article](https://www.ashryan.io/writing/taking-control-of-structured-data-in-ghost/)

Published: 2025-10-21

Notes on setting up structured data a Ghost theme, including how to handle static and dynamic data.

![](https://images.unsplash.com/photo-1583680599407-f73ab374fff4?crop=entropy\&cs=tinysrgb\&fit=max\&fm=jpg\&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDh8fGdob3N0fGVufDB8fHx8MTc2MTAzMjkzOXww\&ixlib=rb-4.1.0\&q=80\&w=2000)

Photo by [Tandem X Visuals](https://unsplash.com/@tandemxvisuals?utm_source=ghost\&utm_medium=referral\&utm_campaign=api-credit) / [Unsplash](https://unsplash.com/?utm_source=ghost\&utm_medium=referral\&utm_campaign=api-credit)

> ⚠️ 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](https://www.hanayou.studio/?ref=ashryan.io). 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](https://www.spectralwebservices.com/blog/adding-new-features-to-ghost-head/?utm_source=chatgpt.com) and her [response to me on the Ghost developer forum](https://forum.ghost.org/t/toggling-modifying-default-ghost-head-structured-data/60537?utm_source=chatgpt.com) for surfacing the `exclude` param).

I tried:

```hbs
{{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:

```hbs
{{! 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:

```hbs
{{!< default}}

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

<main ...
```

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

```html
<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.
