This blog used to run on WordPress. It moved to Ghost, and I migrated it almost entirely through APIs. No clicking through import wizards. Here's how, and the sharp edges.

Exporting from WordPress

The cleanest path is the official @tryghost/migrate tool, which reads the WordPress REST API and produces a Ghost-importable bundle (posts, pages, tags, authors, and downloaded images):

migrate wp-api --url https://example.com --pages true

First gotcha: the tool needs Node 22+ (it uses the built-in node:sqlite module). Node 20 throws ERR_UNKNOWN_BUILTIN_MODULE.

Importing into Ghost

Second gotcha: don't feed the zip to Ghost's importer. Extract the ghost-import.json and POST that to /ghost/api/admin/db/. The zip silently imports nothing. Images get copied into Ghost's content folder separately.

The 2FA wall

Ghost 5 emails a verification code on admin sign-in. With no SMTP configured yet, the session endpoint just returns 500. If you're automating against a fresh install, disable staffDeviceVerification until mail is wired up.

A related one if you script against a local Ghost over plain HTTP: the session cookie comes back marked Secure, and curl will quietly refuse to send it back over an insecure connection. You get a 403 on every authenticated call and nothing in the logs explains why. Capture the header and send it as a plain Cookie: yourself.

The ?source=html trap

The rest, editing posts and pages, is mostly ?source=html on the Admin API, which converts your HTML into Ghost's native format. It is convenient and it is lossy, which I found out the expensive way.

Ghost 5 has two internal document formats: the older Mobiledoc and the newer Lexical. ?source=html converts into whichever one the post already uses, and the HTML-to-Mobiledoc path does not survive everything you give it. On one post it silently dropped a sentence inside a blockquote and URL-encoded the anchor ids on every heading. Nothing errored. The response was a 200 and a post that had quietly lost text.

Worse, the obvious repair makes it worse. Sending {"lexical": "...", "mobiledoc": null} to clear the old format produces an empty post, because Ghost renders from whichever field it considers authoritative and you have just removed it.

The safe sequence is to convert first and write second: PUT with ?convert_to_lexical=true to migrate the post onto Lexical, then PUT the Lexical document itself. And diff the rendered text before and after, mechanically, rather than eyeballing the preview. Take a database dump before any bulk edit.

Postscript

Past the gotchas, Ghost is a pleasant thing to script against. It is also worth saying, since this site is not running it any more: these entries have since moved again, back onto the Jekyll build that serves paulhitt.com today. The API work above is what made that second move straightforward, which is an argument for doing migrations through an API even when a wizard exists.