---
title: "How to ship one h1, title, meta description and JSON-LD on Ghost"
slug: title-meta-schema-ghost
published: 2026-09-22T21:09:09.18975+00:00
updated: 2026-09-22T21:12:41.412494+00:00
author: "Asif Rahman"
author_url: https://masifrahman.com
category: "AI Readiness"
tags: check:C3, platform:ghost, Ghost, structured data, JSON-LD, AI readiness
description: "Ghost fills title tag, meta description and JSON-LD from one theme helper. See which field owns each signal, and why the h1 is the one you can never override."
url: https://aiscan.site/blog/title-meta-schema-ghost
---

## Quick summary

| Signal | Who owns it on Ghost | Where you set it |
|---|---|---|
| `<h1>` | The theme, from your post's literal title | Nothing to set. It's the title field itself |
| `<title>` tag | The `{{meta_title}}` helper | Post settings → Meta data → Meta title |
| Meta description | The `{{meta_description}}` helper, via `{{ghost_head}}` | Post settings → Meta data → Meta description |
| JSON-LD (`Article`) | Ghost core, assembled from other fields | Nowhere directly. It reads your title, tags, excerpt and author bio |

Ghost gets three of these four right without you touching anything. The one it doesn't is the one every Ghost writer eventually fights: there is no field anywhere in the admin called "H1," because your post title is your H1, full stop.

## Ghost owns three of the four signals automatically, and the fourth is the one nobody edits

Fetch a default Ghost theme's `default.hbs` and the mechanism is two lines. Casper, Ghost's own starter theme, puts this in the `<head>`, verified on the theme's own repository:

```html
<title>{{meta_title}}</title>
...
{{ghost_head}}
```

`{{meta_title}}` is a Handlebars helper, not a raw field lookup. According to Ghost's own theme documentation, it "generates automatic meta data by default, but it can be overridden with custom content in the post settings menu." Leave the Meta Title field in your post settings blank and the helper falls back to the post's title. Fill it in and the `<title>` tag diverges from the H1 on purpose. That's a feature, not a bug, and it's why your headline and your search-result title don't have to read identically.

`{{ghost_head}}` does the heavy lifting. According to that same documentation, one helper call outputs the meta description, "Structured data Schema.org microformats in JSON/LD," Open Graph and Twitter Card tags, RSS discovery links, and anything you've dropped into Code Injection, global or per-page. Miss `{{ghost_head}}` in a hand-rolled `default.hbs` and every one of those signals vanishes from the rendered page at once. That's the single most common way an otherwise up-to-date Ghost site fails a structured-HTML check: not a missing setting, a missing helper call.

## The h1 is your post title, and there is no separate override

Casper's `post.hbs` renders the heading with one line: `<h1 class="article-title">{{title}}</h1>`. That `{{title}}` is the raw post title, the same string that feeds your post URL slug and your browser tab. Ghost gives you a Meta Title field to change what search engines and the `<title>` tag show, and a Meta Description field to change the snippet, but no equivalent "H1 override" field. If you want different wording in your heading than in your search result, the only route is to write a Meta Title that differs from the actual title. The H1 will always match the title field, on every stock and custom theme built on Ghost's content API.

This is also where duplicate `<h1>` tags come from on Ghost specifically. Ghost's editor ships a Header card that a writer can drop into the body of a post, and a Header card set to its largest size renders its own `<h1>`. Put one at the top of a post on a theme that already renders `{{title}}` as an `<h1>`, and the page ships two. That's a structural duplicate with nothing to do with the theme being broken, and everything to do with an editorial habit.

## Title and meta description live in Post settings, not the editor toolbar

Both fields sit in the same place: open a post in the editor, click the settings icon, and scroll to **Meta data**. In its own words, Ghost's documentation for that panel advises keeping "the meta title and description within the recommended character limit" and writing them "for humans." Leave either blank and Ghost falls back to the post title and an automated excerpt, rather than leaving the tag empty. That fallback is worth knowing before you conclude a check has failed: an empty-looking field in the editor does not mean an empty tag on the page.

| Field | If you fill it in | If you leave it blank |
|---|---|---|
| Meta title | Becomes the `<title>` tag and the JSON-LD `headline` | Falls back to the post title |
| Meta description | Becomes the meta description tag and the JSON-LD `description` | Falls back to the custom excerpt, then an automatic 50-word excerpt |
| Canonical URL | Overrides the automatic canonical tag | Ghost sets its own canonical automatically |

The Canonical URL field in that same panel is worth using deliberately: it overrides Ghost's automatic canonical tag, which matters when you're republishing a piece that first ran elsewhere and don't want two indexed copies competing.

## JSON-LD is generated for you, and hand-authoring it is a trap

This is the signal with no dedicated field, and reading Ghost's own schema-building source explains why. Ghost core assembles a `schema.org/Article` object per post from fields you're already filling in for other reasons:

| Article field | Comes from |
|---|---|
| `headline` | Meta title (or the post title, if blank) |
| `keywords` | Your tags, joined |
| `description` | Meta description, then custom excerpt, then an automatic excerpt |
| `image` | The post's feature image |
| `datePublished` / `dateModified` | The post's own timestamps |
| `author` | The credited author's profile, bio and social links, as a nested `Person` object |

None of that is a form field labeled "schema." It's a side effect of the post you already wrote.

The trap is trying to fix a JSON-LD failure by hand-writing a `<script type="application/ld+json">` block through Code Injection. Ghost renders both: its own automatic block from `{{ghost_head}}`, and yours. Nothing stops two structured-data blocks from coexisting on one page, but a hand-written block that disagrees with the automatic one, a different headline or a different date, hands a crawler two conflicting answers to the same question instead of one. If a scan reports missing JSON-LD on a page that should have Ghost's automatic block, the fix is almost never Code Injection. It's confirming `{{ghost_head}}` actually made it into the theme's `default.hbs`.

## When you actually need Code Injection for schema

There is a legitimate use: schema types Ghost's own `Article` object doesn't cover, such as `FAQPage` or `HowTo` markup, or `Product` markup on a landing page built as a Ghost page rather than a post. According to Ghost's own help documentation, the feature lives in two places: **Settings → Advanced → Code Injection** for site-wide header and footer code, or, for one specific post, the **Code Injection** section inside that post's own settings panel, which accepts separate header and footer snippets scoped to that page alone. Add a second, different `@type` there and it sits next to the automatic `Article` block without conflict, because the two blocks describe different things. Add a second `Article` block and you're back in the trap above.

## Verify all four signals on the published page

```bash
curl -sL https://yoursite.com/your-post/ -o /tmp/p.html
python3 - <<'PY'
import re
h = open('/tmp/p.html', encoding='utf8', errors='ignore').read()
print("title:", re.search(r'<title>(.*?)</title>', h).group(1))
print("h1 count:", len(re.findall(r'<h1[ >]', h)))
print("meta description:", 'name="description"' in h)
ld = re.findall(r'<script type="application/ld\+json">(.*?)</script>', h, re.S)
types = [re.search(r'"@type":"(\w+)"', b).group(1) for b in ld if re.search(r'"@type":"(\w+)"', b)]
print("JSON-LD blocks:", len(ld), "| types:", types)
PY
```

A healthy Ghost post prints exactly one `<h1>`, a `<title>` that may or may not match it depending on whether you set a custom Meta Title, one meta description tag, and exactly one JSON-LD block typed `Article`, two only if you deliberately added a second, different type through Code Injection. Two `Article` blocks, or a JSON-LD count of zero on a theme that includes `{{ghost_head}}`, both point at a specific, findable cause rather than a mystery.

## Where AIScan fits, and where it doesn't

[AIScan's](https://aiscan.site/) check C3 confirms the four signals above are present in the rendered HTML: one `<h1>`, a `<title>` tag, a meta description, and a JSON-LD block. Run `npx aiscan-cli` against your own domain and it names C3 directly against your live page, free, with no account and no theme access needed. What it stops short of is validating the JSON-LD's contents: a present `Article` block satisfies the check whether or not the `headline` field is stale, the `author` object is well-formed, or the graph would actually pass Google's Rich Results Test. A present block is not the same claim as a correct one, and this check was never built to make that second claim. Pair it with a schema validator when the JSON-LD itself, not just its presence, is in question.

There's a Ghost-specific blind spot worth naming honestly: the check reads the rendered page, so it can't distinguish a theme that never had `{{ghost_head}}` from one that has it but is serving a stale deploy. Both look identical from outside. If a Ghost site that should pass keeps failing, check the theme file before assuming the platform stopped doing its job.

## Fix it, then confirm it, then move on

Ghost's default behavior gets three of these four signals right the moment `{{ghost_head}}` is in your theme, which it is on Casper and on every unmodified theme built to Ghost's own documented conventions. The one thing worth auditing today is whether a custom theme still calls it, and whether any post is quietly carrying a Header-card duplicate `<h1>` left over from an old editorial habit. Scan the page, read the JSON-LD block count, and fix the one signal that's actually missing, not the three that were never broken. From here, [aiscan.site/guides](https://aiscan.site/guides) has the rest of the checklist for what agents can and can't see on your site.

