From ghost
Build Ghost Handlebars themes: required structure, template hierarchy, all contexts, the complete helper reference, custom theme settings, members integration, and image optimisation.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ghost:ghost-themes-templatesThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Ghost themes use Handlebars.js templating (via `express-hbs`). Templates are server-side rendered HTML. This skill covers the full template system — structure, hierarchy, helpers, contexts, custom settings, and members integration.
Ghost themes use Handlebars.js templating (via express-hbs). Templates are server-side rendered HTML. This skill covers the full template system — structure, hierarchy, helpers, contexts, custom settings, and members integration.
For routing (routes.yaml, collections, channels, taxonomies) see ghost-themes-routing.
Announce at start: "I'm using the ghost-themes-templates skill."
theme-root/
├── package.json (required)
├── default.hbs (base layout)
├── index.hbs (required — post list)
├── post.hbs (required — single post)
├── assets/
│ ├── css/
│ │ └── screen.css
│ ├── js/
│ ├── fonts/
│ └── images/
└── partials/ (optional reusable fragments)
{
"name": "my-theme",
"version": "1.0.0",
"license": "MIT",
"author": {
"email": "[email protected]"
},
"config": {
"posts_per_page": 10,
"image_sizes": {
"xs": { "width": 150 },
"s": { "width": 300 },
"m": { "width": 600 },
"l": { "width": 1000 },
"xl": { "width": 2000 }
},
"card_assets": true,
"custom": {
"button_color": { "type": "color", "default": "#15171a" },
"show_sidebar": { "type": "boolean", "default": true },
"layout": { "type": "select", "options": ["classic", "modern"], "default": "classic" },
"cta_text": { "type": "text" },
"hero_image": { "type": "image" }
}
}
}
card_assets: true tells Ghost to inject the built-in card CSS/JS automatically (required for editor cards to render correctly).
Custom setting types: color, boolean, select, text, image. Up to 20 settings per theme. Keys must be lowercase_snake_case. Accessed in templates via {{@custom.key_name}}.
| Template | Purpose | Fallback |
|---|---|---|
default.hbs | HTML shell — <head>, body wrapper, nav, footer | (none) |
index.hbs | Post listing (homepage by default) | required |
home.hbs | Homepage override | → index.hbs |
post.hbs | Single article | required |
page.hbs | Static pages | → post.hbs |
tag.hbs | Tag archive listing | → index.hbs |
author.hbs | Author archive listing | → index.hbs |
error.hbs | All error pages | — |
error-404.hbs | 404 specifically | → error.hbs |
error-4xx.hbs | All 4xx errors | → error.hbs |
private.hbs | Password-protected site | — |
custom-{name}.hbs | Admin-selectable template variants | — |
post-{slug}.hbs | Override for a specific post slug | — |
page-{slug}.hbs | Override for a specific page slug | — |
tag-{slug}.hbs | Override for a specific tag | — |
author-{slug}.hbs | Override for a specific author | — |
Use {{block}} and {{contentFor}} for inheritance:
{{! default.hbs }}
<!DOCTYPE html>
<html>
<head>{{ghost_head}}</head>
<body class="{{body_class}}">
{{> navigation}}
{{{block "content"}}}
{{ghost_foot}}
</body>
</html>
{{! post.hbs }}
{{!< default}}
{{#contentFor "content"}}
<main>
<article class="{{post_class}}">
<h1>{{title}}</h1>
{{content}}
</article>
</main>
{{/contentFor}}
Ghost sets a context on every request. Detect it with {{#is}}:
{{#is "home"}} <p>You're on the homepage</p> {{/is}}
{{#is "post"}} <p>Reading an article</p> {{/is}}
{{#is "page"}} <p>Static page</p> {{/is}}
{{#is "tag"}} <p>Tag archive</p> {{/is}}
{{#is "author"}} <p>Author archive</p> {{/is}}
{{#is "error"}} <p>Error page</p> {{/is}}
Multiple contexts: {{#is "post, page"}}...{{/is}}
Every theme must include these or Ghost will reject it:
{{ghost_head}} {{! In <head> — meta, styles, scripts }}
{{ghost_foot}} {{! Before </body> — scripts, analytics }}
{{body_class}} {{! On <body> element }}
{{post_class}} {{! On the <article> element }}
{{asset "..."}} {{! On every asset reference }}
{{@site.title}}
{{@site.description}}
{{@site.logo}}
{{@site.cover_image}}
{{@site.accent_color}}
{{@site.url}}
{{@site.facebook}}
{{@site.twitter}}
{{title}}
{{excerpt}}
{{excerpt words="30"}}
{{content}}
{{url}}
{{url absolute="true"}}
{{date format="DD MMM YYYY"}}
{{date published_at format="YYYY-MM-DD"}}
{{reading_time}}
{{reading_time minute="min read" minutes="mins read"}}
{{feature_image}}
{{img_url feature_image size="l"}}
{{#foreach authors}}
<a href="{{url}}">{{name}}</a>
{{/foreach}}
{{#foreach tags}}
{{#unless visibility "internal"}}
<a href="{{url}}">{{name}}</a>
{{/unless}}
{{/foreach}}
{{primary_tag.name}}
{{primary_author.name}}
{{primary_author.profile_image}}
{{navigation}}
{{navigation type="secondary"}}
{{pagination}}
Or manually:
{{#if pagination.prev}}
<a href="{{pagination.prev}}">Older</a>
{{/if}}
{{#if pagination.next}}
<a href="{{pagination.next}}">Newer</a>
{{/if}}
{{#prev_post}}
<a href="{{url}}">Previous: {{title}}</a>
{{/prev_post}}
{{#next_post}}
<a href="{{url}}">Next: {{title}}</a>
{{/next_post}}
{{meta_data}} {{! Outputs all structured data / OG / Twitter meta }}
{{readable_url}} {{! Strips protocol for display: example.com/post }}
{{@custom.button_color}}
{{#if @custom.show_sidebar}}
{{> sidebar}}
{{/if}}
{{#match @custom.layout "modern"}}
<div class="layout-modern">...</div>
{{/match}}
{{#foreach}}{{#foreach posts}}
<article>
<h2><a href="{{url}}">{{title}}</a></h2>
<time>{{date format="DD MMM YYYY"}}</time>
</article>
{{/foreach}}
With visibility: {{#foreach posts visibility="all"}} (shows internal tags too)
{{#if}} / {{#unless}}{{#if featured}}
<span class="featured-badge">Featured</span>
{{/if}}
{{#unless @member}}
<a href="#/portal/signup">Subscribe</a>
{{/unless}}
{{#has}}{{#has tag="news"}}
<div class="news-header">...</div>
{{/has}}
{{#has any="tag:news, tag:events"}}...{{/has}}
{{#has author="jane-smith"}}...{{/has}}
{{#has number="first"}}{{/has}} {{! Inside foreach: first item }}
{{#has number="last"}}{{/has}}
{{#has number="even"}}{{/has}}
{{#has number="odd"}}{{/has}}
{{#match}}{{#match @custom.layout "modern"}}
...
{{else match @custom.layout "classic"}}
...
{{/match}}
{{#get}} — Custom API queriesLoad content dynamically within any template:
{{#get "posts" filter="tag:featured" limit="3"}}
{{#foreach posts}}
<a href="{{url}}">{{title}}</a>
{{/foreach}}
{{/get}}
{{#get "tags" limit="all" include="count.posts"}}
{{#foreach tags}}
<span>{{name}} ({{count.posts}})</span>
{{/foreach}}
{{/get}}
{{! Show content based on member status }}
{{#if @member}}
<p>Welcome back, {{@member.firstname}}!</p>
{{else}}
<a href="#/portal/signup">Subscribe to read more</a>
{{/if}}
{{#if @member.paid}}
{{content}}
{{else}}
<a href="#/portal/upgrade">Upgrade for full access</a>
{{/if}}
{{#foreach @member.subscriptions}}
<p>Your plan: {{plan.nickname}} — renews {{date current_period_end format="DD MMM YYYY"}}</p>
{{/foreach}}
Default CTA is Ghost's built-in. Override by creating partials/content-cta.hbs:
{{! partials/content-cta.hbs }}
<section class="paywall">
<h3>This post is for subscribers only</h3>
<a href="#/portal/signup" class="btn-subscribe">Subscribe</a>
</section>
<a href="#/portal/signup">Subscribe</a>
<a href="#/portal/signup/free">Free subscription</a>
<a href="#/portal/signin">Sign in</a>
<a href="#/portal/account">My account</a>
<a href="#/portal/recommendations">Recommendations</a>
<form data-members-form="subscribe">
<input data-members-email type="email" placeholder="Your email">
<input data-members-name type="text" placeholder="Your name">
<button type="submit">Subscribe</button>
</form>
{{img_url feature_image size="l"}}
{{img_url feature_image size="xl" format="webp"}}
Full responsive <picture> element:
<picture>
<source
srcset="{{img_url feature_image size="s" format="webp"}} 300w,
{{img_url feature_image size="m" format="webp"}} 600w,
{{img_url feature_image size="l" format="webp"}} 1000w"
type="image/webp">
<img
srcset="{{img_url feature_image size="s"}} 300w,
{{img_url feature_image size="m"}} 600w,
{{img_url feature_image size="l"}} 1000w"
src="{{img_url feature_image size="m"}}"
alt="{{feature_image_alt}}">
</picture>
Image sizes are defined in package.json under config.image_sizes. Ghost generates each size on first request.
{{search}}
Or trigger manually:
<button data-ghost-search>Search</button>
Keyboard shortcut: Cmd/Ctrl+K. Searches title and excerpt of the most recent 10,000 posts.
partials/
├── navigation.hbs
├── footer.hbs
├── post-card.hbs
└── content-cta.hbs
Include them:
{{> navigation}}
{{> "post-card"}}
{{> "partials/footer"}}
Pass data to partials:
{{> "post-card" post=this featured=true}}
{{asset "css/screen.css"}} {{! Cache-busting URL }}
{{asset "js/app.js"}}
{{plural ../count empty="No posts" singular="% post" plural="% posts"}}
{{translate "Subscribe"}} {{! i18n }}
{{concat @site.url "/rss/"}}
{{encode title}} {{! URL encode }}
{{json post}} {{! Safe inline JSON }}
{{log @site}} {{! Dev console output }}
{{color_to_rgba @site.accent_color 0.5}}
{{contrast_text_color @site.accent_color}}
Always validate before uploading to production:
npm install -g gscan
gscan /path/to/theme
gscan -z theme.zip
Fix all errors (fatal) before uploading. Warnings are safe to ship but worth addressing.
package.json with name, version, config.posts_per_pagecard_assets: true in package.json configindex.hbs and post.hbs present (minimum required templates){{ghost_head}} in <head>, {{ghost_foot}} before </body>{{body_class}} on <body>, {{post_class}} on <article>{{asset "path"}}content-cta.hbs partial created if overriding default paywall CTAghost restart after adding new files)npx claudepluginhub thinkmorestupidless/claude-marketplace --plugin ghostGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.