Membuat Custom Theme Ghost CMS dari Nol

Tutorial bikin custom theme Ghost CMS 5/6 dari nol: file wajib, helper Handlebars yang benar, GScan, dan upload zip ke Design settings.

Membuat Custom Theme Ghost CMS dari Nol
Photo by Juanjo Jaramillo / Unsplash

Ghost adalah CMS yang ringan, fokusnya ke nulis, dan temanya pakai Handlebars. Tema default-nya Casper, tampilannya udah rapi. Kalau kamu mau atur sendiri layout homepage, halaman artikel, font, dan jarak, lebih enak bikin custom theme sendiri.

Pada kesempatan kali ini, saya akan membahas tutorial sederhana tentang cara bikin custom theme Ghost dari nol. Tema ini valid untuk Ghost 5 dan Ghost 6. Kita akan bahas instalasi lokal, file wajib, helper Handlebars, CSS, opsi dark/light, cek pakai GScan, lalu upload zip ke Design settings.

Kebutuhan

  • Node.js LTS yang didukung Ghost (Ghost 6 umumnya Node 22)
  • Ghost-CLI (ghost-cli@latest, pasang global)
  • Terminal
  • Text editor

Urutan ini yang sering ketuker: pasang Ghost-CLI dulu, baru jalankan ghost install local di folder kosong. Jangan kebalik. Langkah CLI lengkap ada di dokumentasi install lokal.

Hasil yang diharapkan

  • Ghost lokal jalan di http://localhost:2368
  • Folder tema hakanam berisi index.hbs, post.hbs, default.hbs, dan package.json
  • Homepage nampilin daftar post, halaman artikel nampilin isi post
  • GScan lolos tanpa error fatal
  • Tema bisa di-zip lalu di-upload lewat Settings → Design

1. Pasang Ghost lokal

Jangan edit tema langsung di production. Satu kurung Handlebars yang kelewat bisa bikin halaman kosong. Kerja di local dulu.

Setelah Ghost-CLI terpasang:

mkdir ~/ghost-dev
cd ~/ghost-dev
ghost install local

ghost install local pakai SQLite dan development mode. Cocok buat ngerjain tema, nggak buat production.

  • Frontend: http://localhost:2368
  • Admin: http://localhost:2368/ghost

Buat akun admin lokal, biarkan Casper aktif sementara.

Perintah harian:

  • ghost start
  • ghost stop
  • ghost restart
  • ghost log

Edit file .hbs yang sudah ada biasanya reload sendiri. File baru atau perubahan package.json butuh ghost restart.

2. File wajib dan struktur folder

Menurut dokumentasi Ghost, tema bisa jalan dengan tiga file saja:

  • index.hbs — daftar post (homepage, dan cadangan arsip tag/author)
  • post.hbs — halaman satu artikel (halaman statis juga pinjam file ini kalau page.hbs belum ada)
  • package.json — nama tema, versi Ghost, jumlah post per halaman

default.hbs nggak masuk daftar wajib, tapi sangat disarankan. Itu kerangka HTML: html, head, header, footer, plus helper sistem Ghost. Template lain tinggal numpang ke kerangka itu, jadi kamu nggak copy-paste boilerplate ke setiap file.

Bikin foldernya di ~/ghost-dev/content/themes/hakanam/. Nama folder itulah nama tema yang muncul di admin.

Kalau lebih nyaman mulai dari yang sudah jadi, silakan fork Casper atau Starter. Casper bagus sebagai referensi helper dan package.json yang lolos GScan. Di sisa artikel ini kita tetap dari nol, biar tiap baris kebaca fungsinya.

Struktur file:

hakanam/
├── assets/
│   ├── css/screen.css
│   └── js/theme.js
├── default.hbs
├── index.hbs
├── post.hbs
└── package.json

Ghost milih template berdasarkan URL.

  • Homepage pakai index.hbs (atau home.hbs kalau ada)
  • Artikel pakai post.hbs
  • Arsip tag/author jatuh ke index.hbs sampai kamu nambah tag.hbs atau author.hbs

Artinya tiga file wajib udah cukup buat blog yang jalan. File lain baru ditambah kalau layout-nya memang perlu beda.

3. package.json

Tanpa package.json yang valid, Ghost menolak tema.

Isi penting untuk Ghost 5/6:

  • engines.ghost bernilai >=5.0.0 (berlaku juga untuk Ghost 6)
  • config.posts_per_page untuk jumlah artikel per halaman
  • config.card_assets true supaya CSS/JS kartu editor ikut diload
  • keywords sebaiknya memuat ghost-theme karena GScan nyari ini
{
  "name": "hakanam",
  "description": "Tema custom minimal untuk Hakanam Article",
  "version": "1.0.0",
  "engines": {
    "ghost": ">=5.0.0"
  },
  "license": "MIT",
  "author": {
    "name": "Rifqi Fandimas Okabryan",
    "email": "[email protected]"
  },
  "keywords": ["ghost", "theme", "ghost-theme"],
  "config": {
    "posts_per_page": 10,
    "card_assets": true
  }
}

name huruf kecil, tanpa spasi. JSON harus valid: pakai double quotes, jangan koma di properti terakhir. Ubah file ini, lalu ghost restart.

4. default.hbs dan helper yang nggak boleh hilang

Ghost nggak memakai {{#extends}}. Pola inheritance-nya dari express-hbs, ditulis di baris pertama index.hbs dan post.hbs:

{{!< default}}

Artinya template ini numpang ke kerangka default.hbs. Isi template masuk ke {{{body}}}. Tiga kurung kurawal, karena yang diinjeksikan HTML, bukan teks biasa.

Dua helper sistem yang wajib ada di kerangka:

  • {{ghost_head}} — taruh sebelum </head>. Mengurus meta description, Open Graph, Twitter Card, JSON-LD, RSS, script API, dan Code Injection head.
  • {{ghost_foot}} — taruh sebelum </body>. Tempat Code Injection foot.

Tanpa keduanya, SEO dan beberapa fitur admin langsung bolong.

Helper wajib lain: {{asset}}, {{body_class}}, dan di halaman post {{post_class}}.

<!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 class="site-header">
        <a class="site-title" href="{{@site.url}}">{{@site.title}}</a>
        <nav>{{navigation}}</nav>
    </header>

    <main class="site-main">
        {{{body}}}
    </main>

    <footer class="site-footer">
        <p>&copy; {{date format="YYYY"}} {{@site.title}}</p>
    </footer>

    <script src="{{asset "js/theme.js"}}"></script>
    {{ghost_foot}}
</body>
</html>

Pakai {{@site.title}} dan {{@site.url}}, bukan {{@blog.title}}. @blog itu pola lama.

Jangan tulis meta description manual di <head>. {{ghost_head}} sudah mengurus itu.

Asset CSS/JS lewat {{asset}} supaya path tetap benar (termasuk kalau Ghost dipasang di subdirectory) dan ada cache-busting.

5. index.hbs: loop daftar post

{{!< default}}

{{#foreach posts}}
<article class="{{post_class}}">
    {{#if feature_image}}
    <a href="{{url}}">
        <img src="{{img_url feature_image size="m"}}" alt="{{title}}">
    </a>
    {{/if}}
    <h2><a href="{{url}}">{{title}}</a></h2>
    <p>{{excerpt words="28"}}</p>
    <p class="post-meta">
        {{#primary_author}}
        <a href="{{url}}">{{name}}</a>
        {{/primary_author}}
        · <time datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMMM YYYY"}}</time>
        {{tags prefix=" · "}}
    </p>
</article>
{{else}}
<p>Belum ada artikel.</p>
{{/foreach}}

{{pagination}}

Di dalam {{#foreach posts}} kamu sudah masuk context tiap artikel, jadi {{title}}, {{url}}, dan {{excerpt}} merujuk ke post itu.

Jangan pakai #each Handlebars biasa. Ghost punya foreach yang context-aware.

Untuk penulis, pakai {{#primary_author}} atau {{authors}}. Helper {{author}} sudah deprecated.

{{pagination}} muncul kalau jumlah post lebih dari posts_per_page.

6. post.hbs: bungkus dengan {{#post}}

Ini bagian yang paling sering salah.

Halaman artikel punya context post, tapi helper judul/konten hanya hidup di dalam block {{#post}}...{{/post}}. Tanpa itu, halaman bisa kosong atau cuma kerangka.

{{!< default}}

{{#post}}
<article class="{{post_class}}">
    {{#if feature_image}}
    <img src="{{img_url feature_image size="l"}}" alt="{{title}}">
    {{/if}}

    <header>
        <h1>{{title}}</h1>
        <p class="post-meta">
            {{#primary_author}}
            <a href="{{url}}">{{name}}</a>
            {{/primary_author}}
            · <time datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMMM YYYY"}}</time>
        </p>
    </header>

    <section class="post-content">
        {{content}}
    </section>
</article>
{{/post}}

{{content}} merender isi editor. Kartu gallery, bookmark, dan kode block akan kelihatan wajar selama card_assets bernilai true.

Nanti kalau layout halaman statis mau beda, tambah page.hbs dengan pola yang sama ({{!< default}} plus {{#post}}, karena page di Ghost tetap object post). home.hbs opsional kalau homepage mau beda dari halaman 2, 3, dst.

7. CSS yang cukup buat mulai

Simpan di assets/css/screen.css. Scope class ke tema kamu. Ghost nge-generate ID di heading dalam artikel, jadi jangan pakai #title atau #content yang terlalu generik.

:root {
    --bg: #f6f4ef;
    --text: #1a1a1a;
    --muted: #5c5c5c;
    --accent: #1d4ed8;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    font-family: system-ui, -apple-system, sans-serif;
    background: var(--bg);
    color: var(--text);
    line-height: 1.65;
}

.site-header,
.site-main,
.site-footer {
    width: min(720px, 92%);
    margin-inline: auto;
}

.site-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 1rem;
    padding: 1.5rem 0;
}

.site-title {
    font-weight: 700;
    text-decoration: none;
    color: inherit;
}

.post-content img,
.site-main img {
    max-width: 100%;
    height: auto;
}

.post-meta {
    color: var(--muted);
    font-size: 0.9rem;
}

8. Opsional: dark dan light mode

Bagian ini boleh dilewati.

Kalau mau, paling bersih pakai CSS variable, hormati prefers-color-scheme, lalu kasih toggle kecil yang nyimpan pilihan di localStorage.

Tambahkan di screen.css:

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --bg: #111111;
        --text: #f2f2f2;
        --muted: #b3b3b3;
        --accent: #93c5fd;
    }
}

:root[data-theme="dark"] {
    --bg: #111111;
    --text: #f2f2f2;
    --muted: #b3b3b3;
    --accent: #93c5fd;
}

Di header default.hbs:

<button type="button" class="theme-toggle" data-theme-toggle>Tema</button>

assets/js/theme.js:

(function () {
    const root = document.documentElement;
    const saved = localStorage.getItem("theme");
    if (saved) {
        root.setAttribute("data-theme", saved);
    }

    const button = document.querySelector("[data-theme-toggle]");
    if (!button) return;

    button.addEventListener("click", function () {
        const next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
        root.setAttribute("data-theme", next);
        localStorage.setItem("theme", next);
    });
})();

Cukup untuk blog personal. Nggak perlu library.

Kalau toggle terasa kedip di first load, pindahkan pembacaan localStorage ke script kecil di <head>. Itu penyempurnaan belakangan, bukan syarat tema valid.

9. Aktifkan di Ghost lokal

  1. Pastikan folder hakanam ada di content/themes/.
  2. Dari ~/ghost-dev, jalankan ghost restart.
  3. Buka admin → Settings → Design → Change theme.
  4. Pilih hakanam, lalu Activate.

Kalau tema nggak muncul: JSON rusak, folder salah tempat (jangan di dalam casper/), atau Ghost belum di-restart. Cek juga ghost log.

10. Validasi dengan GScan

Jangan langsung lempar zip ke production. GScan ngecek error fatal, deprecation, dan kompatibilitas. Ghost Admin juga menjalankan GScan saat upload. Error fatal = tema ditolak.

Dry run dulu di local:

gscan ~/ghost-dev/content/themes/hakanam

Kalau output-nya masuk akal (nggak ada error fatal), baru zip dan upload. Bisa juga upload zip ke gscan.ghost.org.

Temuan yang paling sering:

  • kurang salah satu file wajib
  • {{ghost_head}} atau {{ghost_foot}} hilang
  • engines.ghost kosong
  • JSON invalid
  • masih pakai helper lama (@blog, {{author}})

Warning boleh ditunda. Error fatal beresin dulu.

11. Deploy: zip, upload, activate

Kalau local sudah enak, bungkus temanya dari folder content/themes:

cd ~/ghost-dev/content/themes
zip -r hakanam.zip hakanam -x "hakanam/node_modules/*" -x "hakanam/.git/*" -x "*.DS_Store"

Isi zip harus kelihatan sebagai folder tema berisi package.json, index.hbs, dan post.hbs. Jangan zip folder induk yang nested dua tingkat. Buang node_modules dan .git.

Di site production (atau staging):

  1. Ghost Admin → Settings → Design → Change theme
  2. Upload theme
  3. Pilih hakanam.zip
  4. Tunggu GScan. Kalau lolos, Activate

Download dulu tema yang sedang aktif (menu ... di tema terpasang) sebagai cadangan. Kalau yang baru bermasalah, tinggal activate Casper atau zip cadangan.

Alur kerja:

  1. Edit di local
  2. Refresh browser
  3. GScan
  4. Zip
  5. Upload

Jangan edit file tema langsung di server production.

Pola yang sering salah

Jangan Pakai ini
{{#extends "default"}} {{!< default}} di baris pertama
{{@blog.title}} {{@site.title}}
{{author}} {{#primary_author}} atau {{authors}}
post.hbs tanpa {{#post}} bungkus dengan {{#post}}...{{/post}}
path CSS hardcoded {{asset "css/screen.css"}}
ghost install local tanpa CLI pasang Ghost-CLI global dulu

Template yang berguna belakangan: page.hbs, tag.hbs, author.hbs, home.hbs, plus folder partials/ untuk loop kartu artikel yang dipakai ulang. Belum wajib hari ini.

Referensi resmi

Kalau tiga file wajib plus default.hbs sudah lolos GScan, tema kamu udah bisa dipakai. Langkah berikutnya bisa pecah ke partials, tambah page.hbs / tag.hbs / author.hbs, atau custom settings di package.json.

Semoga tutorial ini membantu! Jika ada pertanyaan bisa langsung tulis komentar di bawah atau kontak saya ia Telegram/Email. Terima kasih.