# Ghost Themes

> Ghost CMS theme development with Handlebars templating. This skill should be used when creating, editing, or debugging Ghost themes, working with Handlebars helpers, configuring custom routes (routes.yaml), defining custom theme settings, or validating themes with GScan. Covers the complete theme file structure, all template contexts, helpers, partials, and the routing system.

- Skill: `bowtiedswan/ghost-themes` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add bowtiedswan/ghost-themes`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bowtiedswan/ghost-themes/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: bowtiedswan (https://skillmd.com/u/bowtiedswan)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bowtiedswan/ghost-themes

---


# 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

```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:

```json
"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

```handlebars
<!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>&copy; {{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:

```handlebars
{{!-- 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

```handlebars
{{!-- 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:

```handlebars
{{#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):

```json
{
  "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:**
```handlebars
{{@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

```yaml
routes:

collections:
  /:
    permalink: /{slug}/
    template: index

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/
```

### Custom Routes

Map URLs to templates without automatic content:

```yaml
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):

```yaml
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

```yaml
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):

```yaml
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

```yaml
collections:
  /:
    permalink: /{slug}/
    filter: 'tag:-hash-de'
  /de/:
    permalink: /de/{slug}/
    template: index-de
    filter: 'tag:hash-de'
```

## Membership in Themes

### Member Detection

```handlebars
{{#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

```handlebars
<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:

```handlebars
{{!< 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)

1. Create `locales/` directory with JSON files named by locale code (`en.json`, `fr.json`)
2. JSON files are key-value pairs: `{"Subscribe": "S'abonner"}`
3. Set language in Ghost Admin General settings
4. Add `<html lang="{{@site.locale}}">` to default.hbs
5. Use `{{t "Key"}}` in templates
6. Placeholders: `{{{t "Subscribe to {blogtitle}" blogtitle=@site.title}}}`
7. Subexpression form `(t "key")` works inside other helpers
8. Restart Ghost after locale file changes

## GScan Validation

Validate themes before upload:

```bash
# 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.

