Build, customize, and deploy Ghost CMS themes. Use this skill whenever the user mentions Ghost themes, Ghost CMS, Handlebars templates (.hbs files), Ghost Admin, Ghost membership/subscription integration, Ghost custom settings, or Ghost content API — even if they don't say "theme" explicitly. Trigger on: building a blog theme, creating a Ghost site, editing .hbs templates, adding member-only content, Ghost hero sections, Ghost routing (routes.yaml), Ghost image optimization, Ghost dark mode, Ghost search, Ghost deploy, gscan validation, Ghost JSON-LD/SEO, or any mention of {{ghost_head}}, {{ghost_foot}}, {{#foreach}}, {{#get}}, {{img_url}}, {{asset}}, @custom, @member, or Portal. Also use when the user has an existing Ghost theme they want to modify, extend, or debug — not just for new themes.
100
100%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Defines every Ghost rendering context, the exact template lookup chain Ghost runs for each one, and the complete data objects available inside each template — synthesized from Ghost's templates.js, context.js, fetch-data.js, the official context docs, and real theme examples.
Ghost sets res.locals.context (an array) before rendering. The context array drives both template selection and helper output. Multiple contexts can be active at once: the home page always carries ['home', 'index']; paginated pages carry ['paged', 'index']; a post in a custom collection carries ['post'].
You test context inside templates with {{#is}}:
{{#is "post"}}
<span class="reading-time">{{reading_time}}</span>
{{/is}}
{{#is "home"}}
<h1>Welcome to {{@site.title}}</h1>
{{/is}}
{{#is "post, page"}}
{{!-- true in either single-entry context --}}
{{> "comments"}}
{{/is}}Context array composition (from context.js):
paged is pushed first when page > 1home is pushed when the URL is exactly /routerOptions.context value is concatenated nextpage, post, or tag is pushed last, driven by the actual data returned@site ObjectAvailable in every context, no block expression needed.
@site.title — site name@site.description — site tagline@site.url — canonical site URL@site.logo — site logo image URL@site.cover_image — site cover image URL@site.icon — site favicon URL@site.twitter — Twitter username@site.facebook — Facebook page name@site.navigation — array of nav items [{label, url}]@site.secondary_navigation — secondary nav array@site.locale — language/locale string (e.g. en)@site.timezone — IANA timezone string@site.codeinjection_head — custom head code injection@site.codeinjection_foot — custom footer code injection@custom.* — theme design settings (e.g. @custom.hero_title, @custom.default_post_template)The index context is the main post list. It is always active on the collection root (/) and on all paginated pages (/page/:num/). Custom collections defined in routes.yaml produce their own named context (e.g. podcast) but share the same template lookup logic.
The home sub-context is only active on the root page (/). When home is active, index is also always active.
/ — home page (contexts: ['home', 'index'])/page/2/ — page 2 and beyond (contexts: ['paged', 'index'])/podcast/ — custom collection root (contexts: ['podcast'])Ghost's getEntriesTemplateHierarchy builds the candidate list from most-specific to least-specific, then walks it and picks the first template that exists in the theme.
For the default index collection:
home.hbs ← only checked on page 1 (frontPageTemplate)
index.hbs ← required fallbackFor a custom named collection (e.g. podcast defined in routes.yaml):
podcast-:slug.hbs ← slug-specific (when slugTemplate + slugParam present)
podcast.hbs ← collection name template
index.hbs ← final fallbackIf routes.yaml specifies templates: for a collection, those names are prepended to the candidate list before the collection-name template.
The index context provides a posts array and a pagination object. There is no wrapping block expression — posts and pagination are top-level.
posts — array of post objects, paginated per posts_per_page in package.json. Each item has the full post shape (see Post Context below). Default includes: authors, tags, tiers (set in fetch-data.js defaultQueryOptions).
pagination — object:
page — current page number (integer)prev — previous page number or nullnext — next page number or nullpages — total number of pagestotal — total number of postslimit — posts per page{{!-- index.hbs --}}
{{!< default}}
<header class="site-header">
<h1>{{@site.title}}</h1>
<p>{{@site.description}}</p>
</header>
<main>
{{#foreach posts}}
<article class="{{post_class}}">
<header>
{{#if feature_image}}
<a href="{{url}}">
<img src="{{img_url feature_image size="m"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
</a>
{{/if}}
<h2><a href="{{url}}">{{title}}</a></h2>
</header>
<section class="post-excerpt">
<p>{{excerpt words="30"}}</p>
</section>
<footer class="post-meta">
<time datetime="{{date format='YYYY-MM-DD'}}">
{{date format="DD MMMM YYYY"}}
</time>
{{#if primary_author}}
by
<a href="{{primary_author.url}}">{{primary_author.name}}</a>
{{/if}}
{{tags prefix=" in " separator=", "}}
</footer>
</article>
{{/foreach}}
</main>
{{pagination}}The post context is active on any individual blog post page. The post object is the most complex model in Ghost and carries special calculated attributes.
Configurable in Ghost Admin (Settings → General). Default: /:slug/. Can be customised per-collection in routes.yaml.
From getEntryTemplateHierarchy in templates.js:
post-:slug.hbs ← slug-specific (e.g. post-my-announcement.hbs)
custom-*.hbs ← whichever custom template was selected in post settings
post.hbs ← required fallbackGhost checks this list from top to bottom, picks the first file that exists in the active theme.
Access via {{#post}}...{{/post}} block expression.
Post object attributes:
id — Object ID of the postcomment_id — legacy incremental ID (pre-1.0) or Object IDtitle — post titleslug — URL-safe slug (also useful as a CSS class name)excerpt — auto-generated or custom excerptcontent — fully rendered HTML bodyurl — canonical URL (always use {{url}} helper, not raw {{post.url}})feature_image — cover image URLfeature_image_alt — cover image alt textfeature_image_caption — cover image caption (may contain basic HTML)featured — boolean, true if post is featuredpage — boolean, false for posts (use {{#is "page"}} to branch)visibility — "public", "members", or "paid"meta_title — custom SEO titlemeta_description — custom SEO descriptionpublished_at — ISO 8601 publish datetimeupdated_at — ISO 8601 last-updated datetimecreated_at — ISO 8601 creation datetimereading_time — estimated reading time in minutes (integer)primary_author — first author object (see Author shape below)authors — array of all author objectstags — array of all tag objectsprimary_tag — first tag object (path expression, not a helper)custom_template — name of the selected custom template (if any)tiers — array of membership tiers with access to this postprimary_author shape (also applies to each item in authors):
id, name, slug, bio, location, websitetwitter, facebookprofile_image, cover_imageurlprimary_tag shape (also applies to each item in tags):
id, name, slug, descriptionfeature_image, accent_colormeta_title, meta_descriptionurl, visibilitycount.posts — post count (only available when explicitly requested){{!-- post.hbs --}}
{{!< default}}
{{#post}}
<article class="{{post_class}}">
{{#if feature_image}}
<figure class="post-feature-image">
<img
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,
{{img_url feature_image size="xl" format="webp"}} 2000w"
sizes="(min-width: 1200px) 1200px, 100vw"
src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
>
{{#if feature_image_caption}}
<figcaption>{{feature_image_caption}}</figcaption>
{{/if}}
</figure>
{{/if}}
<header class="post-header">
{{#primary_tag}}
<a class="post-tag" href="{{url}}">{{name}}</a>
{{/primary_tag}}
<h1 class="post-title">{{title}}</h1>
<div class="post-meta">
{{#primary_author}}
<a href="{{url}}">
{{#if profile_image}}
<img class="author-avatar"
src="{{img_url profile_image size="xxs"}}"
alt="{{name}}">
{{/if}}
{{name}}
</a>
{{/primary_author}}
<time datetime="{{date format='YYYY-MM-DD'}}">
{{date format="DD MMMM YYYY"}}
</time>
{{reading_time minute="1 min read" minutes="% min read"}}
</div>
</header>
<section class="post-content">
{{content}}
</section>
<footer class="post-footer">
{{tags prefix="Filed under: " separator=", "}}
</footer>
</article>
{{/post}}The page context is active on static pages. A page is a special type of post — page: true — but uses the same data object shape. The key differences are template lookup order and the fact that page URLs are always /:slug/ (not configurable).
Always /:slug/. Cannot be customised via routes.yaml (unlike post permalinks).
From getEntryTemplateHierarchy with context === 'page':
page-:slug.hbs ← slug-specific (e.g. page-about.hbs)
custom-*.hbs ← whichever custom template was selected in page settings
page.hbs ← optional page-level fallback
post.hbs ← required ultimate fallback| Aspect | post | page |
|---|---|---|
page attribute | false | true |
| URL configurability | Configurable via permalink settings | Always /:slug/ |
| Template fallback | post.hbs | page.hbs → post.hbs |
| Slug template prefix | post-:slug.hbs | page-:slug.hbs |
{{#is}} check | {{#is "post"}} | {{#is "page"}} |
| Typical usage | Blog posts, articles | About, Contact, landing pages |
Both contexts use {{#post}}...{{/post}} as the block expression. Both carry identical attribute sets. The page attribute on the object is the programmatic way to distinguish them, but {{#is "page"}} in templates is the idiomatic approach.
Identical to post object. The block expression is still {{#post}}...{{/post}}, not {{#page}}. The page attribute will be true.
{{!-- page.hbs --}}
{{!< default}}
{{#post}}
<article class="{{post_class}}">
<header class="page-header">
<h1 class="page-title">{{title}}</h1>
{{#if excerpt}}
<p class="page-excerpt">{{excerpt}}</p>
{{/if}}
</header>
{{#if feature_image}}
<figure class="page-cover">
<img src="{{img_url feature_image size="l"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
</figure>
{{/if}}
<section class="page-content">
{{content}}
</section>
</article>
{{/post}}
{{!-- page-about.hbs: slug-specific override --}}
{{!-- same structure but can add custom sections --}}The tag context is active on tag archive pages. It provides the tag object, a paginated list of posts with that tag, and a pagination object.
/tag/:slug/ — tag page/tag/:slug/page/:num/ — paginated tag pagestag-:slug.hbs ← slug-specific (e.g. tag-photo.hbs)
tag.hbs ← tag-level template
index.hbs ← final fallbackThree top-level objects: tag, posts, pagination.
tag — access via {{#tag}}...{{/tag}}:
id — incremental IDname — display nameslug — URL-safe slugdescription — tag description textfeature_image — cover image URLmeta_title — custom SEO titlemeta_description — custom SEO descriptionurl — canonical tag page URLaccent_color — hex color string (e.g. #ff0000)visibility — "public" or "internal" (internal tags start with #)count.posts — available only when include="count.posts" is set via {{get}}posts — same paginated array as index context (full post shape).
pagination — same object shape as index context.
{{!-- tag.hbs --}}
{{!< default}}
{{#tag}}
<header class="tag-header">
{{#if feature_image}}
<div class="tag-cover"
style="background-image: url({{img_url feature_image size="l"}})">
</div>
{{/if}}
<div class="tag-header-content">
{{#if accent_color}}
<span class="tag-accent" style="background: {{accent_color}}"></span>
{{/if}}
<h1 class="tag-title">{{name}}</h1>
{{#if description}}
<p class="tag-description">{{description}}</p>
{{/if}}
<p class="tag-count">
{{plural ../pagination.total
empty="No posts"
singular="% post"
plural="% posts"}}
</p>
</div>
</header>
{{/tag}}
<main>
{{#foreach posts}}
<article class="{{post_class}}">
<h2><a href="{{url}}">{{title}}</a></h2>
<time datetime="{{date format='YYYY-MM-DD'}}">
{{date format="DD MMMM YYYY"}}
</time>
<p>{{excerpt words="25"}}</p>
</article>
{{/foreach}}
</main>
{{pagination}}The author context is active on author archive pages. It provides the author object, a paginated list of that author's posts, and a pagination object.
/author/:slug/ — author page/author/:slug/page/:num/ — paginated author pagesauthor-:slug.hbs ← slug-specific (e.g. author-john.hbs)
author.hbs ← author-level template
index.hbs ← final fallbackThree top-level objects: author, posts, pagination.
author — access via {{#author}}...{{/author}}:
id — incremental IDname — display nameslug — URL-safe slugbio — biography textlocation — location stringwebsite — personal website URLtwitter — Twitter username (without @)facebook — Facebook usernameprofile_image — avatar image URLcover_image — cover/banner image URLurl — canonical author page URLcount.posts — available only when include="count.posts" is requestedposts — same paginated array as index context (full post shape).
pagination — same object shape as index context.
{{!-- author.hbs --}}
{{!< default}}
{{#author}}
<header class="author-header">
{{#if cover_image}}
<div class="author-cover"
style="background-image: url({{img_url cover_image size="l"}})">
</div>
{{/if}}
<div class="author-profile">
{{#if profile_image}}
<img class="author-avatar"
src="{{img_url profile_image size="s"}}"
alt="{{name}}">
{{/if}}
<h1 class="author-name">{{name}}</h1>
{{#if bio}}
<p class="author-bio">{{bio}}</p>
{{/if}}
{{#if location}}
<p class="author-location">{{location}}</p>
{{/if}}
<div class="author-links">
{{#if website}}
<a href="{{website}}">Website</a>
{{/if}}
{{#if twitter}}
<a href="https://twitter.com/{{twitter}}">@{{twitter}}</a>
{{/if}}
</div>
<p class="author-stats">
{{plural ../pagination.total
empty="No posts yet"
singular="% post"
plural="% posts"}}
</p>
</div>
</header>
{{/author}}
<main>
{{> "loop"}}
</main>
{{pagination}}custom-*.hbs)Any .hbs file in the theme root whose name begins with custom- is automatically discovered by Ghost and shown in the Template dropdown in the post/page settings panel in Ghost Admin. The human-readable label is generated from the filename: custom-full-feature-image.hbs → "Full Feature Image".
Rules:
custom- (lowercase).post.custom_template.Custom templates sit between the slug-specific template and the type fallback:
post-:slug.hbs ← highest priority
custom-*.hbs ← selected custom template (from post.custom_template)
post.hbs ← fallback for posts
page.hbs ← fallback for pages (page context only)Ghost's getEntryTemplateHierarchy inserts postObject.custom_template (the stored template name) at position 2 in the list when it is set. If the theme no longer contains that file, Ghost continues walking the list.
All custom templates must declare their parent layout with {{!< default}} at the top. They receive the same context data as the base post.hbs or page.hbs.
A common pattern is four variants driven by feature image presentation:
{{!-- custom-full-feature-image.hbs --}}
{{!< default}}
<main class="site-main">
{{#post}}
{{> "content" width="full" full=true}}
{{/post}}
{{#is "post"}}
{{#post}}
{{> "comments"}}
{{/post}}
{{> "related-posts"}}
{{/is}}
</main>
{{!-- custom-no-feature-image.hbs --}}
{{!< default}}
<main class="site-main">
{{#post}}
{{> "content" no_image=true}}
{{/post}}
{{#is "post"}}
{{#post}}
{{> "comments"}}
{{/post}}
{{> "related-posts"}}
{{/is}}
</main>
{{!-- custom-wide-feature-image.hbs --}}
{{!< default}}
<main class="site-main">
{{#post}}
{{> "content" width="wide"}}
{{/post}}
{{#is "post"}}
{{#post}}
{{> "comments"}}
{{/post}}
{{> "related-posts"}}
{{/is}}
</main>One approach is for post.hbs to read @custom.default_post_template to apply a site-wide default layout when no per-post custom template has been chosen:
{{!-- post.hbs --}}
{{!< default}}
<main class="site-main">
{{#post}}
{{#match @custom.default_post_template "Full feature image"}}
{{> "content" width="full"}}
{{else match @custom.default_post_template "Narrow feature image"}}
{{> "content" width="narrow"}}
{{else match @custom.default_post_template "Wide feature image"}}
{{> "content" width="wide"}}
{{else}}
{{> "content" no_image=true}}
{{/match}}
{{/post}}
{{#is "post"}}
{{#post}}{{> "comments"}}{{/post}}
{{> "related-posts"}}
{{/is}}
</main>The {{#foreach}} helper (not Handlebars' native {{#each}}) is the correct way to iterate posts in Ghost. It exposes frame data variables prefixed with @:
| Variable | Type | Description |
|---|---|---|
@index | integer | 0-based position in the current iteration window |
@number | integer | 1-based position (@index + 1) |
@first | boolean | true on the first iteration (respects from parameter) |
@last | boolean | true on the last iteration (respects to/limit) |
@even | boolean | true when @index is odd (0-based even = visually odd row) |
@odd | boolean | true when @index is even (0-based odd = visually even row) |
@rowStart | boolean | true when position is the start of a column row |
@rowEnd | boolean | true when position is the end of a column row |
@key | any | The iteration key (array index or object key) |
Note on @even/@odd: The source sets frame.even = index % 2 === 1, meaning @even is true for the second item (0-indexed position 1). This is counterintuitive. In practice use @number with modulo arithmetic for reliable alternating layouts.
limit — maximum number of items to renderfrom — 1-based start index (default: 1)to — 1-based end index (default: length)columns — integer, enables @rowStart/@rowEnd trackingvisibility — filter by post visibility; defaults to "all" for post arrays{{#foreach posts}}
<article class="post-card
{{#if @first}} post-card--featured{{/if}}
{{#if @even}} post-card--even{{else}} post-card--odd{{/if}}">
{{!-- @number for 1-based display --}}
<span class="post-number">{{@number}}</span>
<h2><a href="{{url}}">{{title}}</a></h2>
<p>{{excerpt words="20"}}</p>
{{#if @last}}
<p class="end-of-list">That's all the posts.</p>
{{/if}}
</article>
{{else}}
<p>No posts found.</p>
{{/foreach}}Slicing a subset — show items 2 through 4:
{{#foreach posts from="2" to="4"}}
<li>{{title}}</li>
{{/foreach}}Grid with column tracking — 3-column layout with row boundaries:
{{#foreach posts columns="3"}}
{{#if @rowStart}}<div class="grid-row">{{/if}}
<div class="grid-cell">
<h3><a href="{{url}}">{{title}}</a></h3>
</div>
{{#if @rowEnd}}</div>{{/if}}
{{/foreach}}Looping the first post separately, then the rest:
{{#foreach posts limit="1"}}
<div class="hero-post">
<h1><a href="{{url}}">{{title}}</a></h1>
{{excerpt words="50"}}
</div>
{{/foreach}}
{{#foreach posts from="2"}}
<article>
<h2><a href="{{url}}">{{title}}</a></h2>
</article>
{{/foreach}}The same pagination object is available in all list contexts (index, tag, author, custom collections). It lives at the top level of the template — no block expression needed.
pagination.page — current page number (integer, 1-based)pagination.prev — previous page number or null on page 1pagination.next — next page number or null on last pagepagination.pages — total number of pagespagination.total — total number of matching postspagination.limit — posts per page (from package.json posts_per_page)The {{pagination}} helper renders the built-in pagination UI. To build custom pagination:
{{#if pagination.prev}}
<a href="{{page_url pagination.prev}}">Newer posts</a>
{{/if}}
<span>Page {{pagination.page}} of {{pagination.pages}}</span>
{{#if pagination.next}}
<a href="{{page_url pagination.next}}">Older posts</a>
{{/if}}Referencing pagination.total from inside a block expression context (e.g. inside {{#tag}}):
{{plural ../pagination.total
empty="No posts"
singular="% post"
plural="% posts"}}The ../ traverses out of the tag scope to reach the top-level pagination object.
index is the built-in default collection. It has no configurable name; the context array always contains 'index' (or 'home' + 'index' on page 1). It uses index.hbs / home.hbs.
A collection is a named group of posts defined in routes.yaml. The context array contains the collection's route name (e.g. 'podcast' for a collection mounted at /podcast/). Each collection:
collection-router.js: this.context = [this.routerName])templates: in routes.yaml that are prepended to the candidate listfilter:, order:, limit:, and data: keys to control what posts appearFrom routes.yaml:
collections:
/podcast/:
permalink: /podcast/{slug}/
filter: tag:podcast
template: podcast
data:
tag: tag.podcastThis produces the lookup chain:
podcast.hbs
index.hbsIf templates: [podcast-featured] were added:
podcast-featured.hbs
podcast.hbs
index.hbsWhen slugTemplate: true is set on the router and a :slug param is in the URL, Ghost also prepends name-:slug.hbs (e.g. author-john.hbs). This is how taxonomy routers (tag, author) work — they set slugTemplate: true so each individual taxonomy page can have a custom template.
When navigating to an individual post permalink within a collection, the router switches to type: 'entry' and resets context to ['post']. This means the post uses post.hbs / post-:slug.hbs / custom-*.hbs, not any collection-specific template.
| Context | URL Pattern | Template Chain (first match wins) | Block Expression |
|---|---|---|---|
| home | / | home.hbs → index.hbs | none (top-level posts, pagination) |
| index (paged) | /page/:num/ | index.hbs | none |
| post | /:slug/ | post-:slug.hbs → custom-*.hbs → post.hbs | {{#post}} |
| page | /:slug/ | page-:slug.hbs → custom-*.hbs → page.hbs → post.hbs | {{#post}} |
| tag | /tag/:slug/ | tag-:slug.hbs → tag.hbs → index.hbs | {{#tag}} |
| author | /author/:slug/ | author-:slug.hbs → author.hbs → index.hbs | {{#author}} |
| collection | /:name/ | [custom templates] → :name.hbs → index.hbs | none |
| error-404 | /* (not found) | error-404.hbs → error-4xx.hbs → error.hbs | none |
| error-500 | /* (server error) | error-500.hbs → error-5xx.hbs → error.hbs | none |