Laurie and I have been running a travel blog for a few years. It started on WordPress, which is where most things start. WordPress is fine. It works, it’s familiar, and there’s a plugin for everything. But fine and familiar eventually stops being enough.
The specific irritations were a long time coming. The editor had drifted somewhere between Gutenberg blocks and the classic toolbar, and writing a post felt like navigating a product that was perpetually mid-migration. The admin dashboard was cluttered with upsells and notifications. Performance required plugins layered on top of plugins. We were running a blog, not a commerce site, and the overhead for that simple use case had grown beyond what felt proportionate.
Ghost promised the inverse: a platform built for writers, with a clean editor, fast defaults, and no plugin ecosystem to manage. It’s opinionated in a way that WordPress deliberately is not. That suited us.
The content migration — posts, images — was straightforward enough that it doesn’t warrant much discussion. Ghost has an import tool, and the WordPress exporter produces something it can consume. That part worked. The more interesting problem was the theme.
The Organization Problem
Travel content doesn’t fit neatly into a chronological blog feed. When someone arrives looking for posts about Japan, they don’t want to scroll backwards through time hoping things cluster. They want to browse by place.
WordPress handles this with categories and tags, and we’d been using that. Ghost has tags but no categories — everything is flat. That sounds like a limitation, but it pushed us toward a cleaner model: one tag per location level. A post about Kyoto gets tagged kyoto and japan. The country tag covers the trip, the city tag covers the post’s specific location.
The navigation needed to express that hierarchy, even though the data doesn’t enforce it. The solution was a megamenu — a dropdown under “Trips” that organizes destinations by country, with city tags listed under each.
<li class="nav-item-submenu nav-trips">
<a href="#">Trips</a>
<div class="nav-megamenu">
<div class="megamenu-section">
<h3><a href="/tag/japan/">Japan</a></h3>
<ul>
<li><a href="/tag/tokyo/">Tokyo</a></li>
<li><a href="/tag/kyoto/">Kyoto</a></li>
<li><a href="/tag/hiroshima/">Hiroshima</a></li>
...
</ul>
</div>
...
</div>
</li>
The hierarchy is hardcoded in the template, which is the honest thing to do. Ghost’s navigation system doesn’t support nested structures, and trying to derive a two-level taxonomy from flat tags dynamically would have been more fragile than just writing the HTML. If we add a new destination, we update the template and deploy. That happens rarely enough that the maintenance cost is low.
The current coverage is ten countries — Japan, Mexico, Morocco, Portugal, Spain, UK, France, Netherlands, Lebanon, and Thailand — with between one and seven city-level tags each. The most recent trip always goes to the front of the list — Japan is there now.
Building the Theme
Ghost themes use Handlebars — the same templating system as many static site generators, but executed server-side by Ghost at request time. The learning curve from WordPress’s PHP templates is shallow; the main adjustment is understanding Ghost’s data access patterns and which helpers are available.
The post template does a few things worth noting. Feature images display with a title overlay and primary tag badge rather than sitting above the headline in the usual arrangement. The effect is closer to magazine layout than standard blog, which suits travel photography.
<div class="post-feature-image-wrapper">
<img src="{{img_url feature_image size="xl"}}" alt="{{title}}">
<div class="post-title-overlay">
{{#primary_tag}}<span class="post-tag-overlay">{{name}}</span>{{/primary_tag}}
<h1>{{title}}</h1>
</div>
</div>
The “More from [location]” section at the bottom of each post pulls seven posts with the same primary tag, then removes the current post in client-side JavaScript before displaying up to six. The {{#get}} helper in Ghost lets you query posts inline in a template, which is more capable than it sounds — it’s what makes related posts work without a plugin.
The lightbox was built from scratch rather than pulling in a library. It collects all gallery and content images on page load, wires up click handlers, and handles keyboard navigation (arrows, Escape). This is one of those things that’s tempting to reach for a library to solve, but the vanilla implementation is about thirty lines and has no dependencies to maintain.
Deployment Automation
The other thing I wanted from the start was a deployment pipeline. Ghost doesn’t have a built-in mechanism for theme version control — the default workflow is to upload a zip file through the admin UI. That’s fine once, but not for iterative development where you’re making small changes and want to test them quickly.
The Ghost Admin API supports theme uploads and activation programmatically. The GitHub Actions workflow zips the theme directory, installs @tryghost/admin-api, and uses it to upload and activate in a single step.
const api = new GhostAdminAPI({
url: process.env.GHOST_ADMIN_API_URL,
key: process.env.GHOST_ADMIN_API_KEY,
version: 'v5.0'
});
const result = await api.themes.upload({ file: 'theme.zip' });
await api.themes.activate(result.name);
The API key format is id:secret — a colon-separated pair. There’s a non-obvious thing here: the GhostAdminAPI constructor expects the full combined key string, not the split components. An earlier version of the workflow split the key before passing it, which fails silently in a confusing way. The fix was to pass the raw environment variable directly and let the library handle parsing.
The workflow triggers on push to main and is also manually dispatchable. The zip excludes .git, node_modules, .github, and markdown files — everything that’s infrastructure rather than theme. Deployment takes about twenty seconds from push to active.
What the Iteration Looked Like
The commit history tells the story of what was harder than expected. Navigation bugs showed up repeatedly as we added destinations — Thailand’s top-level link pointed to the wrong place for a while, Lebanon went through several rounds of capitalization fixes, and some location tags had underscores where Ghost expected hyphens. None of these were hard problems; they were the kind of thing you only find by clicking around the live site.
The background image URL broke at some point during a Ghost upgrade and required updating in the CSS. The related posts footer was pulling tags incorrectly. The Admin API key handling needed refactoring after the split-vs-full confusion. Small things, but they accumulated into a picture of what ongoing theme maintenance actually looks like — less about architecture, more about the dozen small places where assumptions quietly stopped being true.
The theme is at v2.17.0. That version number isn’t meaningful in any strict sense, but it reflects genuine iteration: a thing that got built, then got used, then got fixed repeatedly based on use.
What I’d Do Differently
The megamenu is hardcoded, and that’s the right call for now — but it means adding a new destination is a code change, not a content change. Ghost’s native navigation doesn’t support nesting, so there’s no obvious fix. One option would be a structured custom setting that the template reads to generate the menu dynamically; Ghost supports custom theme settings that editors can update without touching code. I haven’t built that because the current destinations are stable, but it’s the right next step if the list keeps growing.
The WordPress-to-Ghost content migration worked, but the image paths came across pointing at the old host and needed updating in bulk. Doing that systematically before migration rather than discovering it post-migration would have been cleaner. Ghost’s bulk post editing tools don’t extend to regex-replacing content fields, so this was more manual than it needed to be.
Ghost is a better platform for what we’re doing. The editor is cleaner, the performance defaults are better, and the theming system is simple enough to build against without fighting it. The things it doesn’t do — nested navigation, categories, plugin ecosystem — turn out to matter less in practice than they seemed to when we were deciding. The constraint of flat tags pushed us toward a tagging discipline that’s actually easier to maintain than the looser WordPress setup was.
The megamenu and deployment workflow are straightforward to replicate — the Ghost Admin API docs cover the upload and activation calls, and the GitHub Actions setup is a handful of steps once you have the API key split figured out.