Blog

How to migrate your post analytics from Ghost(Pro) to self-hosted Ghost

Captain's log, stardate d180.y42/AB

Moving with luggages

When I moved a site from a personal project off Ghost(Pro) onto a self-hosted Ghost instance on a Hetzner server, I was able to port everything cleanly: posts, members, images, tags, URLs. But there was something I couldn’t migrate automatically: post-level analytics. To be fair: I knew that beforehand because the Ghost folks are very upfront with this particular issue.

Every newsletter I'd ever sent had open rates, click rates and signup numbers attached to it. After the migration, that part is simply blank, so you start from scratch collecting analytics from the very first issue you publish after the migration. If I am not mistaken, it’s because Ghost(Pro) and self-hosted Ghost use different products for analytics.

Anyhow, out of prudence, I backed up my old site, so they were not lost forever (I had the numbers), but I needed to get them where they belonged.

Here's how I got them back, what broke along the way, and the decisions that made it work.

Why the numbers disappear

Ghost's import/export format carries content, not engagement. The `.json`/`.zip` export moves your posts and members, but the email stats (opens, clicks, conversions) live in a different set of database tables (`emails`, `email_batches`, member open/click events) that the export simply doesn't include.

So on the new instance, on Hetzner, those tables are empty for every migrated post/issue. The admin posts list is a data-driven app: it reads open/click rates straight from the database: no rows, no numbers. There's no setting to toggle, and no supported way to import old analytics back in.

So first of all, I did a CSV export of post analytics from the old instance: one row per post, with `sends`, `opens`, `clicks`, `signups` and `paid_conversions`. Then I committed to getting that CSV data to show up in the admin UI with the new analytics.

Decision #1: overlay, not database surgery

There were two ways to do this:

Option A: backfill the database. Fabricate the `emails` and event records so Ghost renders the numbers natively, permanently, with no extra code. This is the "proper" way, and it's also a trap: it means writing fake "this was emailed" records into a production database, the click data lives in a different place than the open data, and you also don’t have the assurance that this will be compatible going forward or that any other update won’t break your data. Risky, if you ask me. Unnecessarily risky for something as relatively useless as old analytics data.

Option B: a client-side overlay. Ship the historical analytics data stored in a static JSON file, and add a small script to the admin that reads that file and paints the numbers onto the posts list. It changes no data, it's reversible by deleting one line, and if it's wrong, nothing breaks. The worst case is that something might look a bit off-centered, but that’s it.

I went with option B. The data is historical and read-only by nature; there's no reason to risk the database to display it. I just wanted to be able to see it in the admin panel, not query it from the database.

Step 1: CSV → a lookup keyed by slug

First, turn the CSV into something a browser can consume instantly: a JSON object keyed by post slug.

Why slug and not post ID? Because the migration regenerated post IDs. The IDs in the old CSV don't match the new instance. Slugs, on the other hand, survived perfectly the migration, so the public URLs are identical before and after.

A tiny zero-dependency Node script does the conversion:

// build-legacy-analytics.js — node build-legacy-analytics.js in.csv out.json
function slugFromUrl(url) {
  const path = url.replace(/^[a-z]+:\/\//i, '').split('/').slice(1);
  const segments = path.filter(Boolean);
// "emailed only" posts have no public page: /email/<uuid>/ — skip them
  if (!segments.length || segments[0] === 'email') return null;
    return segments[segments.length - 1];
}

for (const line of rows) {
  const row = parseLine(line); // handles quoted fields (tags have commas)
  const slug = slugFromUrl(row[urlIdx]);
  if (!slug || !isNumeric(row[opensIdx])) continue;
  result[slug] = {
    sends: toNum(row[sendsIdx]),
    opens: Number(row[opensIdx]),
    clicks: toNum(row[clicksIdx]),
    signups: toNum(row[signupsIdx]),
    paid: toNum(row[paidIdx]),
  };
}

Two small things that mattered:

  • The CSV parser handles quoted fields. My `tags` column contains values like "Business, Operations, Management". A naive `split(',')` helps separating those rows cleanly.
  • Some rows have no public slug. "Emailed-only" posts point at `/email/<uuid>/` rather than a real post URL. They have no page in the admin to attach to, so they're skipped and logged.

The output is a ~5 KB file, `legacy-analytics.json`, that I ship as a static theme asset (the titles in the code are placeholders):

{
  "post1-title": { "sends": 160, "opens": 79, "clicks": 3, "signups": 0, "paid": 0 },
  "post2-title":{ "sends": 159, "opens": 85, "clicks": 3, "signups": 0, "paid": 0 }
}

Step 2: hooking into the admin

The admin lives at `/ghost/` and is served from a single file `index.html`. There's no official injection point, but Ghost serves that file fresh on every request (with a content-based ETag), so adding one line to it is safe and sticks:

<script src="/assets/js/legacy-admin-analytics.js" data-cfasync="false" defer></script>

Two details here:

  • `data-cfasync="false"`. My site is behind Cloudflare, and Rocket Loader was rewriting every script tag on the admin (including mine) into its deferred loader. Marking the tag tells Rocket Loader to leave it alone so it runs exactly as written.
  • A `?v=<hash>` cache-buster. The tag isn't versioned by Ghost, so I append an MD5 of the file. Change the script, the hash changes, browsers refetch. A small shell script recomputes it on each deploy.

The overlay logic itself keeps the JS-in-core to a single line. All the actual code lives in a normal, version-controlled theme asset.

Step 3: painting the numbers on

The posts list is a client-rendered SPA behind auth, with infinite scroll. So the script can't just run once on load. It has to react to rows appearing, whose shape is:

  1. On start, fetch two things in parallel: the legacy JSON, and the Admin API's list of posts (`/ghost/api/admin/posts/?fields=id,slug`). The second call uses the admin's own session cookie and gives me the ID → slug map I need.
  2. Build a combined `postId → {opens, clicks, ...}` lookup.
  3. Attach a `MutationObserver` to the list and, whenever rows render, decorate any that match.
Promise.all([
  fetch('/assets/data/legacy-analytics.json').then(r => r.json()),
  fetch('/ghost/api/admin/posts/?limit=all&fields=id,slug',
  { credentials: 'same-origin' }).then(r => r.json()),
  ]).then(([legacy, { posts }]) => {
  const byId = {};
  posts.forEach(p => { if (legacy[p.slug]) byId[p.id] = legacy[p.slug]; });
  decorate(byId);
  new MutationObserver(() => decorate(byId))
  .observe(document.body, { childList: true, subtree: true });
});

Decision #2: don't fight Ghost's UI, become it

My first version rendered a nicely styled little block of my own because I failed to instruct Claude Code to use existing CSS classes. It looked almost right, but the icons were slightly different and the columns didn't line up with the posts that did have native stats.

In existing pre-packaged software like Ghost, it’s always better to instruct your coding agent to look for existing elements & styles instead of inventing new ones. Most of the times, there’ll be something there for you.

Therefore, I instructed Claude to reproduce Ghost's own styles instead of inventing my own. I inspected a real "sent" row and copied its structure exactly: the `gh-post-analytics-email-metrics` container, the `gh-post-list-metrics` wrappers, the `gh-post-list-analytics-tooltip`, right down to Ghost's lucide icons (`mail-open`, `mouse-pointer-click`, `send`). You can do that by giving it access to your browser (it cannot automatically do it on its own because it’s hidden behind a login) or you can download the html code of that particular page and ingest it into Claude Code.

The result came immediately and got it right:

  • Alignment came automagically. Ghost gives each metric a fixed-width column (`.gh-post-list-metrics { width: 92px }`). Use the same class, get the same column. I measured it afterwards: my injected open/click columns land on the exact same pixel as the native rows above and below them.
  • The hover tooltip came automagically too. Ghost's CSS already shows `.gh-post-list-analytics-tooltip` on hover of its container. Reuse the class, and the "Newsletter performance" popup (Sent / Opens / Clicks) just works with no custom popup code at all.

The only styling I kept is a one-liner: legacy numbers render a hair lighter than native ones, so at a glance you can tell which figures are imported history versus live data. But you can skip this. I wanted to have this for my own enjoyment, to be able to see where did I do the migration.

One subtle trap worth flagging: my overlay writes into the DOM, and my own `MutationObserver` watches the DOM, so a careless write triggers itself forever. The fix is to only ever write when the value actually changes:

const setText = (el, v) => { if (el && el.textContent.trim() !== String(v)) el.textContent = v; };

Deploying it: no downtime, and surviving upgrades

There's no CI/CD here: it's a self-hosted box, deploys are `scp` over SSH. The nice thing about this design is that nothing needs a restart: the theme asset and the admin's `index.html` are both read per-request, so a copy-up is live instantly.

The one operational nuance is upgrades. A `ghost update` rebuilds the admin from scratch and wipes my injected `<script>` line. So the re-apply lives in a small, idempotent shell script I run after any upgrade; it backs up `index.html`, strips any old copy of the tag, and re-inserts a freshly-hashed one.

What it can't do

… and I’m fine with that!

  • Emailed-only posts stay blank. The handful of issues whose old URL was `/email/<uuid>/` never had a public slug, so there's nothing to join them on. Matching those by title is possible, but it wasn't worth the fragility for a handful of posts. You can match them by title and date, if you so want it.
  • It's an overlay, not real data. The numbers are painted on by a script; they're not in the database and won't appear in Ghost's API. If you want to compute the analytics, grab the data in the HTML because it won’t appear in the database.
  • It's coupled to Ghost's current markup. Because I lean on Ghost's own classes, a big admin redesign could break the layout.

The takeaway

To be entirely honest, I wanted to go the hard route and write the data into the database, mostly because this kind of things are trivial and quasi-free nowadays with Claude Code and the like. However, the Ghost official documentation discouraged this approach.

The instinct with "the platform lost my data" is to try to force the data back into the platform and I like challenges, but seniority also gives you this sort of acquiescence when you know that “good enough is good enough”. A little HTML, CSS and JS spared me from having to deal with convoluted database models.

And also, it looks like the data was always there. Which, in a sense, it was, and it’s all that matters.

Share this article

Related articles

Claude Cowork

What I actually do with Claude Cowork

From Slack noise to curated briefings: How Claude Cowork transformed how I oversee projects and team alignment.

Read full article
Performance review

Using AI to run performance reviews: how we evaluate our team with Claude

How we connected Claude to our remote stack to eliminate bias and bureaucracy from performance reviews, while keeping human judgment firmly at the center.

Read full article
Brain tech

How MarsBased integrates AI across development and operations

At MarsBased, AI has become part of our daily workflow: from how developers write code to how non-technical teams create, translate, and manage content. Here’s a look at how we’re Claude Code and similar tools to improve efficiency across the entire company.

Read full article