⚠️ 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 …
⚠️ 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:
- All data destined for static JSON-LD is stored in
data/schema.json
within the theme repo - When I run the build process (
npm run build
), the first thing that runs is a custom script calledscripts/build-schema.mjs
- The custom script parses through the JSON file and generates partials in
partials/schema/
containing the desired JSON-LD - 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
andArticle
, 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:
- For Ghost theme developers, adding structured data is relatively straightforward for static entities.
- Asserting control over dynamic JSON-LD isn’t practical today. It’s better just to let Ghost emit it.
- 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.