Ghost Themes
Overview
Ghost themes use Handlebars templating with the express-hbs library. Themes consist of .hbs template files, assets (CSS/JS/images), and a package.json manifest. The routing system supports collections, taxonomies, channels, and custom routes via routes.yaml.
When to Use
- Creating a new Ghost theme from scratch
- Modifying or extending an existing Ghost theme
- Working with Handlebars helpers in Ghost templates
- Configuring custom routes, collections, or channels
- Adding custom theme settings to
package.json - Debugging template rendering or context issues
- Validating a theme with GScan before upload
Required Theme Structure
theme-name/
├── assets/
│ ├── css/
│ │ └── screen.css
│ ├── fonts/
│ ├── images/
│ └── js/
├── partials/ # Reusable template fragments
│ └── post-card.hbs
├── default.hbs # Base layout (recommended)
├── index.hbs # REQUIRED - Post list template
├── post.hbs # REQUIRED - Single post template
└── package.json # REQUIRED - Theme manifest
package.json
{
"name": "my-theme",
"description": "A custom Ghost theme",
"version": "1.0.0",
"license": "MIT",
"author": {
"name": "Author Name",
"email": "author@email.com",
"url": "https://author-site.com"
},
"screenshots": {
"desktop": "assets/screenshot-desktop.jpg",
"mobile": "assets/screenshot-mobile.jpg"
},
"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": {}
},
"docs": "https://docs.example.com"
}
Image Sizes
Define responsive image sizes that the {{img_url}} helper can use:
"image_sizes": {
"xs": { "width": 150 },
"s": { "width": 300 },
"m": { "width": 600 },
"l": { "width": 1000 },
"xl": { "width": 2000 },
"xxl": { "width": 2000, "height": 1200 }
}
Usage: {{img_url feature_image size="m"}}
Card Assets
Set "card_assets": true to automatically include CSS/JS for Ghost editor cards (bookmarks, galleries, buttons, etc.).
Template Hierarchy
Layout
- default.hbs — Base layout wrapping all pages. Use
{{{body}}}to inject page content.
Content Templates
| Template | Context | Fallback |
|---|---|---|
index.hbs |
Homepage / post list | Required |
home.hbs |
Homepage specifically / |
index.hbs |
post.hbs |
Single post | Required |
page.hbs |
Static page | post.hbs |
post-:slug.hbs |
Specific post by slug | post.hbs |
page-:slug.hbs |
Specific page by slug | page.hbs → post.hbs |
custom-*.hbs |
Selectable in admin | post.hbs / page.hbs |
Archive Templates
| Template | Context | Fallback |
|---|---|---|
tag.hbs |
Tag archive | index.hbs |
tag-:slug.hbs |
Specific tag | tag.hbs → index.hbs |
author.hbs |
Author archive | index.hbs |
author-:slug.hbs |
Specific author | author.hbs → index.hbs |
Special Templates
| Template | Context |
|---|---|
private.hbs |
Password-protected site form |
error.hbs |
Generic error page |
error-404.hbs |
404 not found |
error-4xx.hbs |
4xx client errors |
robots.txt |
Override default robots.txt |
default.hbs Layout
<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{meta_title}}</title>
<link rel="stylesheet" href="{{asset "css/screen.css"}}">
{{ghost_head}}
</head>
<body class="{{body_class}}">
<header>
<a href="{{@site.url}}">{{@site.title}}</a>
{{navigation}}
</header>
<main>
{{{body}}}
</main>
<footer>
<p>© {{date format="YYYY"}} {{@site.title}}</p>
</footer>
{{ghost_foot}}
</body>
</html>
Handlebars Helpers
Data Helpers
| Helper | Description |
|---|---|
{{@site}} |
Global site settings: title, description, logo, icon, accent_color, locale, url, facebook, twitter, navigation, secondary_navigation, members_enabled |
{{@config}} |
Theme config from package.json: posts_per_page, image_sizes |
{{@custom}} |
Custom theme settings: {{@custom.setting_name}} |
{{@page}} |
Page-level settings (show_title_and_feature_image) |
{{@member}} |
Current member data (when logged in): name, email, uuid, paid, status |
{{authors}} |
Post author(s) output |
{{tags}} |
Post tag(s) output |
{{tiers}} |
Available membership tiers |
{{content}} |
Full post content as HTML |
{{excerpt}} |
Post excerpt (custom or auto-generated) |
{{date}} |
Formatted date output |
{{img_url}} |
Image URL with size/format options |
{{price}} |
Formatted price output |
{{title}} |
Post/page title |
{{url}} |
Resource URL |
{{navigation}} |
Site navigation HTML |
{{comments}} |
Ghost commenting system |
{{recommendations}} |
Recommended sites list |
{{total_members}} |
Total member count (rounded) |
{{total_paid_members}} |
Paid member count (rounded) |
{{social_url}} |
Full social profile URL |
{{readable_url}} |
Human-readable URL display |
{{link}} |
Dynamic link with classes |
{{meta_data}} |
SEO structured data |
{{post}} |
Post data within post context |
Functional Helpers
| Helper | Description | Example |
|---|---|---|
{{#foreach}} |
Loop over collections | {{#foreach posts}}...{{/foreach}} |
{{#get}} |
Custom API queries | {{#get "posts" filter="featured:true" limit="3"}} |
{{#has}} |
Test for properties | {{#has tag="photo"}}...{{/has}} |
{{#if}} |
Conditional | {{#if featured}}...{{/if}} |
{{#is}} |
Check route context | {{#is "home"}}...{{/is}} |
{{#match}} |
Compare values | {{#match @custom.layout "grid"}}...{{/match}} |
{{#unless}} |
Inverse conditional | {{#unless feature_image}}...{{/unless}} |
Utility Helpers
| Helper | Description |
|---|---|
{{asset}} |
Cache-busting asset URLs: {{asset "css/screen.css"}} |
{{body_class}} |
Dynamic body CSS classes |
{{post_class}} |
Post container CSS classes |
{{ghost_head}} |
Required in <head> — outputs meta, styles, scripts |
{{ghost_foot}} |
Required before </body> — outputs scripts |
{{pagination}} |
Pagination HTML output |
{{prev_post}} / {{next_post}} |
Adjacent post navigation |
{{reading_time}} |
Estimated reading time |
{{search}} |
Pre-styled search button |
{{encode}} |
URL-encode text |
{{concat}} |
Concatenate strings |
{{plural}} |
Pluralize based on count |
{{translate}} |
i18n translation output |
{{log}} |
Debug logging (dev mode) |
{{link_class}} |
Dynamic nav link classes |
{{split}} |
Split string for iteration |
{{#block}} / {{#contentFor}} |
Template data passing |
The {{#get}} Helper
Make custom Content API queries within templates:
{{!-- Featured posts --}}
{{#get "posts" filter="featured:true" limit="3" include="tags,authors"}}
{{#foreach posts}}
<h3><a href="{{url}}">{{title}}</a></h3>
{{/foreach}}
{{/get}}
{{!-- Related posts by tag --}}
{{#get "posts" filter="tags:[{{tags autolink='false' separator=','}}]+id:-{{id}}" limit="3"}}
{{#foreach posts}}
<a href="{{url}}">{{title}}</a>
{{/foreach}}
{{/get}}
{{!-- All tags with post counts --}}
{{#get "tags" include="count.posts" limit="all"}}
{{#foreach tags}}
<a href="{{url}}">{{name}} ({{count.posts}})</a>
{{/foreach}}
{{/get}}
The {{#has}} Helper
{{!-- Check for specific tag --}}
{{#has tag="photo"}}
<span class="photo-badge">Photo</span>
{{/has}}
{{!-- Check for multiple tags (OR) --}}
{{#has tag="photo, video"}}
<span>Media post</span>
{{/has}}
{{!-- Check for author --}}
{{#has author="john"}}
<p>Written by John</p>
{{/has}}
{{!-- Check for feature image --}}
{{#has feature_image}}
<img src="{{feature_image}}">
{{/has}}
The {{#is}} Helper
Check the current template context:
{{#is "home"}}Welcome!{{/is}}
{{#is "post"}}Single post view{{/is}}
{{#is "page"}}Static page{{/is}}
{{#is "tag"}}Tag archive{{/is}}
{{#is "author"}}Author archive{{/is}}
{{#is "paged"}}Not on page 1{{/is}}
Custom Theme Settings
Define in package.json under config.custom (max 20 settings):
{
"config": {
"custom": {
"typography": {
"type": "select",
"options": ["Modern sans-serif", "Elegant serif"],
"default": "Modern sans-serif",
"description": "Choose the main typography"
},
"show_sidebar": {
"type": "boolean",
"default": true,
"group": "homepage"
},
"accent_color": {
"type": "color",
"default": "#ff5500"
},
"hero_image": {
"type": "image",
"group": "homepage"
},
"cta_text": {
"type": "text",
"default": "Subscribe now",
"description": "Call to action button text"
}
}
}
}
Setting Types: select, boolean, color, image, text
Groups: homepage, post (default: site-wide)
Visibility: Conditionally show settings using NQL: "visibility": "typography:[Modern sans-serif]"
Template Access:
{{@custom.typography}}
{{@custom.accent_color}}
{{#if @custom.show_sidebar}}...{{/if}}
{{#match @custom.typography "Elegant serif"}}
<link href="serif-font.css" rel="stylesheet">
{{/match}}
Routing (routes.yaml)
Located at content/settings/routes.yaml. Editable via Ghost Admin (Settings > Labs) or directly on the server (requires restart).
Default Configuration
routes:
collections:
/:
permalink: /{slug}/
template: index
taxonomies:
tag: /tag/{slug}/
author: /author/{slug}/
Custom Routes
Map URLs to templates without automatic content:
routes:
/features/: features
/about/team/:
template: team
data: page.team # Load page with slug "team"
/podcast/rss/:
template: podcast-feed
content_type: text/xml # Non-HTML output
Data binding: data: page.{slug}, data: post.{slug}, data: tag.{slug}, data: author.{slug}
Collections
Group posts into distinct sections (each post belongs to exactly one collection):
collections:
/blog/:
permalink: /blog/{slug}/
template: blog
filter: primary_tag:blog
/podcast/:
permalink: /podcast/{slug}/
template: podcast
filter: primary_tag:podcast
order: published_at desc
data: tag.podcast
Permalink Variables
{id}, {slug}, {year}, {month}, {day}, {primary_tag}, {primary_author}
Taxonomies
taxonomies:
tag: /topic/{slug}/ # Custom prefix
author: /writer/{slug}/
Set to empty to disable: taxonomies:
Channels
Filtered views that don't affect post URLs (posts can be in multiple channels):
routes:
/apple-news/:
controller: channel
filter: tag:[iphone,ipad,mac]
/editors-column/:
controller: channel
filter: tag:column+primary_author:cameron
Channels automatically get an RSS feed at /{channel}/rss/.
Route Properties
| Property | Description |
|---|---|
template |
Handlebars template file (without .hbs) |
permalink |
URL pattern with variables |
filter |
NQL filter for content |
order |
Sort: published_at desc, featured desc, published_at desc |
data |
Bind Ghost API data to route |
rss |
Enable/disable RSS (default: true) |
content_type |
MIME type (default: HTML) |
controller |
channel for channel behavior |
Multilingual Sites
collections:
/:
permalink: /{slug}/
filter: 'tag:-hash-de'
/de/:
permalink: /de/{slug}/
template: index-de
filter: 'tag:hash-de'
Membership in Themes
Member Detection
{{#if @member}}
<p>Welcome back, {{@member.name}}</p>
{{#if @member.paid}}
{{!-- Paid member content --}}
{{else}}
{{!-- Free member content --}}
{{/if}}
{{else}}
<a href="#/portal/signup">Sign up</a>
{{/if}}
Content Gating
Content visibility is controlled by post visibility setting. Ghost automatically handles content gating — the {{content}} helper only outputs what the current member is allowed to see.
Portal Links
<a href="#/portal/signin">Sign in</a>
<a href="#/portal/signup">Sign up</a>
<a href="#/portal/account">Account</a>
<a href="#/portal/account/plans">Change plan</a>
Template Inheritance
Child templates declare their parent with {{!< default}} at the top of the file:
{{!< default}}
{{!-- This template inherits from default.hbs --}}
{{#foreach posts}}
<article>
<h2><a href="{{url}}">{{title}}</a></h2>
{{excerpt}}
</article>
{{/foreach}}
{{pagination}}
Use {{#block}} / {{#contentFor}} for named slots between parent and child templates.
Internationalization (i18n)
- Create
locales/directory with JSON files named by locale code (en.json,fr.json) - JSON files are key-value pairs:
{"Subscribe": "S'abonner"} - Set language in Ghost Admin General settings
- Add
<html lang="{{@site.locale}}">to default.hbs - Use
{{t "Key"}}in templates - Placeholders:
{{{t "Subscribe to {blogtitle}" blogtitle=@site.title}}} - Subexpression form
(t "key")works inside other helpers - Restart Ghost after locale file changes
GScan Validation
Validate themes before upload:
# Online: https://gscan.ghost.org/
# CLI:
npm install -g gscan
gscan /path/to/theme
gscan /path/to/theme.zip
GScan checks for errors, deprecations, and compatibility issues.
Resources
references/helpers_reference.md
Detailed reference for all Handlebars helpers with complete parameter documentation and examples.