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
This reference covers the complete build system used in the base template:
scripts/build.ts, the Bun CSS segfault workaround, dev/watch mode, zip packaging
for Ghost upload, PostCSS, TypeScript entrypoints, source maps, multi-entrypoint
builds, and how {{asset}} handles cache-busting.
{{asset}}All source lives under assets/src/ and all build output goes to
assets/built/. The built directory is gitignored.
assets/
├── src/
│ ├── app.ts ← JS entrypoint (imports components)
│ ├── app.css ← CSS entrypoint (@imports components)
│ ├── base/
│ │ ├── variables.css ← CSS custom properties
│ │ ├── reset.css ← Box-sizing, body defaults
│ │ └── typography.css ← Headings, links
│ ├── layout/
│ │ ├── container.css ← Container widths
│ │ └── responsive.css ← Breakpoint overrides
│ └── components/
│ ├── members-form.ts ← Membership form state
│ ├── search.ts ← Search toggle
│ ├── header.css ← Site header
│ ├── hero.css ← Hero section
│ ├── post-feed.css ← Post card grid
│ ├── post-single.css ← Single post/page + Ghost cards
│ ├── members-cta.css ← Members signup CTA
│ ├── author.css ← Author archive
│ ├── tag.css ← Tag archive
│ ├── error.css ← Error pages
│ ├── pagination.css ← Pagination controls
│ └── footer.css ← Site footer
└── built/ ← gitignored, generated by bun run build
├── app.css
└── app.jsTemplates reference built files through {{asset "built/app.js"}}, never
the src path. The source is organized into component-sized files:
base/ — CSS custom properties, reset, typographylayout/ — Container widths, responsive breakpointscomponents/ — One .css and/or .ts file per UI componentCSS uses @import statements resolved by postcss-import at build time.
TypeScript uses ES module import statements resolved by Bun's bundler.
Full annotated source:
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import postcss from "postcss";
import postcssImport from "postcss-import";
const srcDir = path.resolve(import.meta.dir, "../assets/src");
const outDir = path.resolve(import.meta.dir, "../assets/built");
if (!existsSync(outDir)) {
mkdirSync(outDir, { recursive: true });
}
const isWatch = process.argv.includes("--watch");
async function buildJS() {
const result = await Bun.build({
entrypoints: [
path.join(srcDir, "app.ts"),
],
outdir: outDir,
minify: !isWatch,
target: "browser",
sourcemap: isWatch ? "inline" : "none",
});
if (!result.success) {
console.error("JS build failed:");
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
}
async function buildCSS() {
const cssEntry = path.join(srcDir, "app.css");
const cssOut = path.join(outDir, "app.css");
const src = readFileSync(cssEntry, "utf8");
const plugins: postcss.AcceptedPlugin[] = [postcssImport()];
const result = await postcss(plugins).process(src, {
from: cssEntry,
to: cssOut,
});
writeFileSync(cssOut, result.css);
}
async function build() {
await Promise.all([buildJS(), buildCSS()]);
console.log("Build succeeded:");
console.log(" assets/built/app.js");
console.log(" assets/built/app.css");
}
await build();
if (isWatch) {
console.log("\nWatching for changes...");
const { watch } = await import("fs");
watch(srcDir, { recursive: true }, async () => {
await build();
});
}entrypoints
An array of absolute file paths. Bun resolves all import/require calls
starting from these files and bundles everything reachable into a single output
file per entrypoint. The output filename mirrors the entrypoint basename
(app.ts → assets/built/app.js). Bun strips TypeScript types natively.
outdir
Absolute path to the output directory. Bun creates it if missing (the
mkdirSync guard above handles the first run). All output files land here
flat — no subdirectory mirroring.
minify: !isWatch
When true, Bun runs its built-in minifier (whitespace removal, identifier
mangling, dead-code elimination). Disabled during watch mode so that error
messages and stack traces reference readable variable names.
target: "browser"
Tells Bun to produce browser-compatible output: no process, require, or
Node-specific globals are left in the bundle. Use "node" only for build
scripts themselves, never for Ghost theme assets.
import.meta.dir
Bun's equivalent of __dirname. Always resolves relative to the file that
contains the expression — safe even when scripts/build.ts is invoked from a
different working directory (e.g., bun run scripts/build.ts from the theme root).
result.success / result.logs
Bun.build never throws. A failed build returns { success: false, logs: [] }
where each log entry has .message, .level, and source location. The script
calls process.exit(1) on failure so that bun run zip aborts early.
To add a second JS bundle (e.g., a separate admin script):
entrypoints: [
path.join(srcDir, "app.ts"),
path.join(srcDir, "admin.ts"),
],Bun produces assets/built/app.js and assets/built/admin.js automatically.
No additional configuration is needed.
The base template uses postcss-import to resolve CSS @import chains.
This is necessary because Bun 1.3.x has a segfault when CSS files are
passed as entrypoints to Bun.build().
How it works: app.css is the entrypoint, containing only @import
statements. PostCSS reads the file, resolves all imports recursively, and
outputs a single bundled CSS file to assets/built/app.css.
Adding a new CSS component:
Create a .css file in the appropriate directory and @import it from
app.css:
/* assets/src/components/gallery.css */
.gallery { display: grid; gap: 1rem; }
/* assets/src/app.css — add the import */
@import "./components/gallery.css";When Bun 1.4 ships a stable CSS bundler, you can simplify by removing
PostCSS and adding app.css directly to the Bun entrypoints array. Check
the Bun changelog for "CSS bundler segfault" before switching.
Adding CSS minification: Install cssnano and add it to the plugins:
bun add -d cssnano
import cssnano from "cssnano";
const plugins: postcss.AcceptedPlugin[] = isWatch
? [postcssImport()]
: [postcssImport(), cssnano()];Adding CSS nesting or autoprefixer:
bun add -d postcss-nesting autoprefixer
import postcssNesting from "postcss-nesting";
import autoprefixer from "autoprefixer";
// Add after postcssImport() in the plugins arrayThe dev script in package.json:
"dev": "bun run scripts/build.ts --watch"Internally, scripts/build.ts detects --watch via:
const isWatch = process.argv.includes("--watch");When watch mode is active:
minify: false).fs.watch monitors the entire assets/src/
directory recursively.Livereload strategy
Bun does not include a built-in livereload server. The recommended approaches are:
Ghost's built-in livereload — Ghost Pro and local Ghost (Ghost CLI) have
a livereload mechanism that picks up changes to assets/built/ automatically
when running in development mode. Run Ghost locally alongside bun run dev
and Ghost will inject the livereload script.
browser-sync — For a standalone livereload server that also proxies Ghost:
bunx browser-sync start \
--proxy "localhost:2368" \
--files "assets/built/**,**/*.hbs" \
--no-notifyManual refresh — The simplest fallback. Save → switch to browser → F5. Adequate for most template and CSS work where the change is obvious.
Watch latency
Node's fs.watch on Linux uses inotify, which is near-instantaneous. The Bun
build itself for a small theme typically completes in under 100 ms. Total
latency from save to rebuilt files is usually under 200 ms.
Ghost requires themes to be uploaded as .zip archives. The zip script
in package.json:
"zip": "bash scripts/zip.sh"The scripts/zip.sh script runs the build and packages the theme, excluding dev artifacts. Breaking down the exclusions:
node_modules/* — never ship dependencies; Ghost doesn't need themdist.zip — prevents the archive from containing itself.git/* — no version control metadata in uploaded themesassets/src/* — source files are not needed at runtime; only assets/built/ shipsscripts/ — all build/test scripts are dev artifactsbun.lock — lockfile is only meaningful for local installs*.log — any stray log filesRunning the zip:
bun run zipThis produces dist.zip in the theme root. Upload it via Ghost Admin →
Design → Change theme → Upload theme.
What Ghost validates on upload (gscan):
Ghost runs gscan against the uploaded zip. The most common failures when using a Bun-based build are:
package.json at zip root (the zip must include it)assets/built/app.css or assets/built/app.js (build must run
before zip).hbs filesRun bunx gscan . locally before zipping to catch these issues early.
Zip from a CI pipeline:
bun run scripts/build.ts
zip -r dist.zip . \
-x 'node_modules/*' 'dist.zip' '.git/*' \
'assets/src/*' 'scripts/*' 'bun.lock' '*.log'Split into two commands so the exit code of the build step is distinct from the zip step.
The base template organizes CSS into three directories:
base/ — Foundation styles loaded first: CSS custom properties
(variables.css), box model reset (reset.css), heading/link defaults
(typography.css)components/ — One file per UI component, named to match the HBS
partial or template it styles (e.g., header.css → partials/header.hbs)layout/ — Structural concerns: container widths (container.css),
responsive breakpoint overrides (responsive.css, loaded last)Import order in app.css matters — base first, components in any order,
responsive overrides last.
The base template uses TypeScript by default. Bun handles .ts files
natively — no tsc, no ts-loader, no separate compilation step. Bun
strips types at build time using its built-in transpiler.
The tsconfig.json in the theme root is for editor tooling and type
checking only:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["assets/src/**/*.ts"]
}"noEmit": true means tsc is only used for type checking — Bun does the
actual compilation. Run bun run typecheck (alias for tsc --noEmit) to
catch type errors without building.
Adding a new component:
Create a .ts file in assets/src/components/ and import it from app.ts:
// assets/src/components/gallery.ts
export function initGallery(): void {
// ...
}
// assets/src/app.ts
import { initGallery } from "./components/gallery";Bun resolves the import chain and bundles everything into a single
assets/built/app.js.
Mixing .ts and .js
Bun resolves cross-extension imports transparently. A .ts file can
import a .js module and vice versa.
Source maps are not enabled in the base template by default. Add them via:
const result = await Bun.build({
entrypoints: [...],
outdir: outDir,
minify: !isWatch,
target: "browser",
sourcemap: isWatch ? "inline" : "none",
});sourcemap values:
"none" — no source maps (default; recommended for production)"inline" — embeds the map as a base64 data URI at the end of the output
file; no extra .map file on disk"external" — writes a separate app.js.map file alongside app.jsWhen to enable:
--watch mode), "inline" maps give browser DevTools
the original source file and line numbers with no extra network request.bun run build), keep "none". Source maps expose your
original source to anyone who opens DevTools, and Ghost serves
assets/built/ publicly with no access restrictions.Ghost-specific consideration:
Ghost does not serve .map files with special headers. If you use
"external" maps and push to production, the .map files will be publicly
accessible at https://yoursite.com/assets/built/app.js.map. This is
usually acceptable for open-source themes but undesirable for commercial ones.
Use "inline" only in local development or strip maps from the zip by adding
'assets/built/*.map' to the zip exclusion list.
Split your JavaScript when different pages need substantially different code and you don't want to ship unused code to every visitor.
entrypoints: [
path.join(srcDir, "app.ts"), // shared utilities + homepage
path.join(srcDir, "post.ts"), // post-specific: syntax highlighting, etc.
],Bun outputs assets/built/app.js and assets/built/post.js. Reference them
conditionally in templates:
In default.hbs (loads on every page):
<script src="{{asset "built/app.js"}}"></script>In post.hbs (loads only on post pages):
<script src="{{asset "built/post.js"}}"></script>Shared module deduplication
When multiple entrypoints import the same module, Bun by default inlines the module into each bundle (no shared chunk). For Ghost themes this is usually fine — theme JS is small. If you need a shared chunk to avoid duplicating a large dependency across bundles:
const result = await Bun.build({
entrypoints: [...],
outdir: outDir,
minify: !isWatch,
target: "browser",
splitting: true, // enables code-splitting / shared chunks
});With splitting: true, Bun emits a chunk-*.js file containing the shared
code. You must then load it before the page-specific bundles. Because Ghost
templates don't have a bundler-aware loader, you need to add the chunk <script>
tag manually — or accept the duplication and skip splitting.
For per-page CSS, add the CSS copy to your build loop:
for (const name of ["app", "post"]) {
copyFileSync(
path.join(srcDir, `${name}.css`),
path.join(outDir, `${name}.css`)
);
}Then in the appropriate template:
<link rel="stylesheet" href="{{asset "built/post.css"}}">{{asset}}Ghost's {{asset}} helper resolves a theme-relative path and appends a
cache-busting query string automatically:
{{asset "built/app.js"}}
→ /assets/built/app.js?v=abc123def456The v= parameter is derived from the theme's version string in package.json
combined with the file's last-modified timestamp. Ghost recalculates it on
every theme activation and on every Ghost restart.
Practical implications:
app.abc123.js hashing).v= parameter.bun run dev, the v= parameter updates each
time Ghost restarts, not on each file change. If you need instant
cache-busting during development, use browser DevTools → Network → Disable
cache, or use browser-sync (which bypasses the cache entirely via its proxy).Always use {{asset}} for built files — never hardcode paths:
{{! correct}}
<link rel="stylesheet" href="{{asset "built/app.css"}}">
<script src="{{asset "built/app.js"}}"></script>
{{! wrong — no cache-busting, breaks on CDN installs}}
<link rel="stylesheet" href="/assets/built/app.css">{{asset}} with a leading slash
The ghost_root asset prefix (e.g., /content/themes/my-theme/) is prepended
automatically. You must not add a leading slash to the path argument:
{{asset "built/app.js"}} ← correct
{{asset "/built/app.js"}} ← wrong, produces double-slash on some installs