How to migrate from Ghost to Eleventy

My tango blog used to run on the Ghost publishing platform. It's fast and comfortable, but since the rest of this website is built statically on Eleventy, I wanted move away from Ghost. Here is what I learned in the process.

Reasons to switch

Let's have a look at my reasons. Yours and mine are going to be different.


My tango blog is already mostly static. There was backend with the Ghost admin app that I used to compose my articles. Once published, I had no reason to go there.

Ghost has some integrations. I didn't use them. It has a membership functionality. I didn't use it.

Perhaps surprisingly, Ghost doesn't, to this day, have built-in comments. I missed them, and integrated the Isso self-hosted comments, there and here. It's a little bit of Python code with SQLite. Not that there's much commenting going on here anyway.

That one fear that made me do it

I built a solid collection of articles, and I would be supremely pissed if I lost them.

I ran Ghost on SQLite. I trust it but hey, it's a database. I wasn't going to check it in into the repository, and I set up semi-manual backups; still, I felt uneasy. Something could go wrong, the database might become corrupted.

All my uploads (mostly images accompanying my posts) are on the filesystem, not in the database. I'd have to back them up, too, if I wanted to be thorough.

And then there's the rest of this website, which lives happily in a Git repository. One that automatically takes care of all my content is backed up in several places. I knew that if I could take my blog's content and put it in my git repo, my fear would go away instantly.

Other considerations played little to no role. Ghost is pretty fast, and the blog's performance didn't go up after the initial migration. You can self-host it at no cost, so price was also not a concern.

How I did it

Before diving in, I assumed this was going to be a lengthy process. Not so much. The initial import took about a day and I went live in a week.

First, Ghost lets you export your content into JSON. Put this JSON in an empty folder and install the package ghost-to-md. The version I used was 1.3.0.

npm i ghost-to-md

Then point it to your exported JSON like this:

.\node_modules\.bin\ghost-to-md $PATH_TO_GHOST_JSON_FILE

The script will chew through your JSON and create a folder ghost-to-md-output with all your glorious articles converted to Markdown.

I was pretty impressed by this, because I checked the SQLite database and Ghost stores your articles as HTML, and so a back conversion had to take place.

It succeeded with remarkable precision with only a few misses:

You'll notice the pattern here.

I was going to go through all my articles with a magnifying glass anyway, so it wasn't such a big deal. Just be aware of it. Anything contained within an <iframe...> gets quietly ignored.

How much work is left for you depends on your existing Eleventy setup. I will describe what I had to do.

The basics

Cool URLs don't change, right?

As luck would have it, the ghost-to-md exported the slug property of each post's metadata, which happens to be what Ghost to used as the final part of the path in the URL.

And so, if the slug were my-awesome-cd-review, the relative URL would be /tango/my-awesome-cd-review/. Given that by default, Eleventy maps folder and file names to URL components, all it took me to keep the URLs the same was to place my posts in a folder ./$ROOT_CONTENT_DIR/tango and instruct Eleventy to use the slug metadata variable to construct the permalink.

The way to do this is to make each post use the same template, and in the template specify this format of the permalink:

permalink: /tango/{{ slug }}/

No additional configuration / programming required.

The fine-tuning

I copied the script's output under my _site folder, where all my content lives. The articles I post under /code use slightly different metadata, but I was going to build new templates for my tango content, and so that wasn't an immediate problem.

Adapting my Ghost theme to Eleventy took just a little while. I took all the styles and HTML as-is from the theme. Ghost uses Handlebars while I use mostly Nunjucks on Eleventy, so it took a bit to convert the variables, loops, etc.

The 90% of the work was going through the articles and locating the missing bits. There might've been a way to automate that but I wanted to make sure with my own eyes anyway.

There were occasional hiccups.


For instance, the RSS that Ghost generates uses the post's id from the database for the guid property on the item element. That part wasn't exported to the Markdown, and so I ran a custom script on the IDs like this, saving the output to a file:

const file = require("./tango-is-alive.ghost.2021-01-23-12-03-06.json");
console.log(JSON.stringify(file.db[0] => ({slug: p.slug, id:}))))

I placed the file in the _site/data ensuring that it would be available to Eleventy during build. Then the template for my RSS feed has this bit to use the "legacy" Ghost ID for the existing content:

<guid isPermaLink="false">{% if %}{{ }}{% else %}{{ }}|{{ }}{% endif %}</guid>

Obviously my new articles aren't going to have this ID but I wanted to avoid making the RSS aggregators think ALL of my content is new / fresh / updated, and spamming my subscribers with everything I've got so far.

Responsive images

Ghost generates multiple versions of the images you upload via the Admin app so that the browser can fetch whichever version is most appropriate given the available screen width.

I had these versions already generated for the existing content. Wouldn't it be nice to have a feature like this for my future posts, however?

It turned out Ghost uses the sharp library for this task, and so could I. It took me about an hour to write a script that resizes everything that hasn't been already resized, and I hooked it up to be executed on each build like this (this is from within eleventy.js):

  eleventyConfig.on('afterBuild', () => {
    // Run me after the build ends
    // I started writing in 2018, and so that's why we start there :)
    let years = _.range(2018, new Date().getFullYear() + 1).map(_.toString);

  async function makeImagesAsync(years) {
    for (const year of years) {
      await makeResponsiveImages(year);
    console.log("Generated responsive images");

You'll notice there's a missing await at the end of the afterBuild hook. Last time I checked, there wasn't a way to hook up an async event handler here.

Here's the full script that does the resizing:

"use strict";

const _ = require("lodash");
const fs = require("fs");
const path = require("path");
const sharp = require("sharp");

const responsiveImageSizes = [320, 640, 960, 1920];

const imageRoot = path.join('build', 'tango', 'content', 'images');

async function makeResponsiveImages(rootDir) {
  let realRoot = path.join(imageRoot, rootDir);
  let dir;
  try {
    dir = await fs.promises.opendir(realRoot);
  } catch (oops) {
    console.error(`Could not open ${dir}`);
  for await (let dirent of dir) {
    if (dirent.isDirectory()) {
      await makeResponsiveImages(path.join(rootDir,;
    if (dirent.isFile() && isImage( {
      await resizeImage(path.join(rootDir,;

function isImage(filename) {
  const supported = ["jpg", "png"];
  const filenameNorm = (filename || "").toLowerCase();
  return _.some(supported, s => filenameNorm.endsWith(`.${s}`));

async function resizeImage(filename) {
  const dirs = filename.split(path.sep);
  const file = dirs.pop();
  const dir = path.join(... dirs);
  await Promise.all( => {
    const wDir = `w${width}`;
    const respDir = path.join(imageRoot, "size", wDir, dir);
    if (!fs.existsSync(respDir)) {
      fs.mkdirSync(respDir, { recursive: true });
    if (fs.existsSync(path.join(respDir, file))) {
    return sharp(path.join(imageRoot, filename)).resize(width, undefined, {
        withoutEnlargement: true
      .toFile(path.join(respDir, file));

module.exports.makeResponsiveImages = makeResponsiveImages;

The deployment

I have come across intermittent build failures, for which I submitted an issue. Those worried me enough that I introduced a two-step deployment process:

  1. First, build everything as normal
  2. Verify that no errors came up during build
  3. Rename the build folder to live, from which content is actually served

One Powershell script takes care of the first point, then another does the third. It would be nice if I could fully automate this, e.g. by re-generating the website upon each git commit to master, but then I would have to trust the software again. For my purposes here, no need.

Upon the first deployment of my now fully static website, I had to change the nginx config to stop redirecting the /tango/ path to the Ghost instance, and finally stop it when I confirmed all was well.

Closing thoughts

I've been super happy with Eleventy since I first rolled out the current version of my website. Other than their pandering to BLM on their front page, without which I would be even happier, the software is solid and most importantly, my website doesn't rely on it after it's built.

It's good to know that everything is backed up in git, that there's no database to corrupt or lose, and should I get hit with a wave of visitors, the performance is only limited by nginx.

Do I miss Ghost's visual editor? Yes, it's fantastic, and so is VS Code. Whoever is managing his or her website using Eleventy is likely technical enough to handle writing Markdown (or HTML) by hand.

Go static, young man. Go static!


If you feel like we'd get along, you can follow me on Twitter, where I document my journey.

Published on

What do you think? Sound off in the comments!