Portage / Routes / Ghost → Astro
// Route specification
Ghost → Astro
A complete crossing for a Ghost publication: posts, pages, tags, authors, images, and routes mapped to Astro content collections. Members and theming are out of scope — content and SEO equity move intact.
01Overview
This route carries a Ghost publication into an Astro project as a set of content collections backed by Markdown or MDX files. The goal is a clean, version-controlled content tree with no Ghost runtime, no database, and every URL preserved.
Portage reads from a Ghost site two ways — the read-only Content API for a live site, or a Ghost JSON export for an offline or decommissioned one — normalizes both into a single internal manifest, then writes Astro-native files with rewritten asset paths and a generated redirect map.
Posts, pages, tags, authors, feature images, inline media, publish dates, SEO and Open Graph metadata, canonical URLs, and the full permalink structure.
02Extraction
Pick whichever source matches the state of the site. Both produce the same manifest, so every later stage behaves identically.
A · Content API (live site)
Best for a running Ghost site. Read-only; needs only a Content API key from Settings → Integrations → Add custom integration. Portage paginates posts, pages, tags, and authors with ?include=tags,authors&formats=html,lexical.
$ npx portage extract --from ghost \ --url https://blog.example.com \ --key $GHOST_CONTENT_KEY --to ./astro-project → 142 posts · 6 pages · 38 tags · 4 authors · 318 images referenced
B · JSON export (offline / archived)
Best for a site you can no longer reach. Use the full export from Settings → Migration → Export. Portage reads the db[0].data tables (posts, posts_tags, tags, users, posts_authors) directly.
$ npx portage extract --from ghost \ --export ./ghost-export.json --to ./astro-project
The Content API never returns drafts or email-only posts; the JSON export does. Use the export (or the authenticated Admin API) if you need unpublished content. Add --include-drafts to carry them across as draft: true.
03Content mapping
Each Ghost post and page becomes one Markdown/MDX file. Ghost fields map to frontmatter as follows; the post body becomes the file content. Fields not listed are preserved under a ghost: namespace in frontmatter so nothing is silently dropped.
| Ghost field | Type | Astro frontmatter | Notes | |
|---|---|---|---|---|
| title | string | → | title | Required. |
| slug | string | → | (filename) | Becomes the file slug and collection entry id. |
| custom_excerpt | string | → | description | Falls back to generated excerpt if empty. |
| html · lexical | richtext | → | (body) | Converted to Markdown/MDX — see §04. |
| feature_image | url | → | heroImage | Downloaded & localized — see §05. |
| feature_image_alt | string | → | heroImageAlt | Ghost 5+. |
| feature_image_caption | string | → | heroImageCaption | HTML caption flattened. |
| published_at | date | → | pubDate | ISO 8601, coerced by zod. |
| updated_at | date | → | updatedDate | Omitted if equal to pubDate. |
| tags[] | string[] | → | tags | Public tags only; internal # tags filtered — see §08. |
| primary_author · authors[] | string[] | → | authors | Primary author first. |
| meta_title | string | → | seo.title | Defaults to title. |
| meta_description | string | → | seo.description | Defaults to description. |
| og_* · twitter_* | mixed | → | seo.openGraph · seo.twitter | Image fields localized. |
| canonical_url | url | → | canonicalURL | Preserves cross-posted canonicals. |
| featured | boolean | → | featured | |
| visibility | enum | → | access | public → published; members·paid → gated — see §07. |
| status | enum | → | draft | draft·scheduled → draft: true. |
| codeinjection_head · _foot | string | → | ghost.codeInjection | Carried, never executed — see §07. |
04Content transforms
Ghost stores body content as Lexical (Ghost 5) or Mobiledoc (Ghost 4 and earlier), and also exposes rendered html. Portage prefers the structured format and falls back to html, then converts to clean Markdown. Ghost's editor "cards" are mapped to portable equivalents.
| Ghost card | Output (Markdown) | Output (MDX) | |
|---|---|---|---|
| code / markdown | → | Fenced block with language | Fenced block |
| image + caption | → |  + italic caption | <Figure> |
| gallery | → | Image sequence | <Gallery> |
| callout | → | Blockquote w/ emoji | <Callout> |
| toggle | → | <details> | <Toggle> |
| bookmark | → | Link + description | <Bookmark> |
| button | → | Markdown link | <Button> |
| embed (YT, X, …) | → | Link fallback | <Embed> |
| html | → | Raw HTML passthrough | Inline JSX/HTML |
Use --content markdown for the most portable output, or --content mdx to keep rich cards as components. When MDX is selected, Portage scaffolds the referenced components (Callout, Figure, Gallery, …) as stubs under src/components/ghost/ so the project builds immediately.
05Assets
Ghost serves images from /content/images/YYYY/MM/, often with generated size variants (/size/w600/) and a __GHOST_URL__ placeholder in exports. Portage resolves every reference, downloads the original, and rewrites the path.
- Download & dedupe — each asset is fetched once and content-hashed; duplicates collapse to a single file.
- Strip size variants — the original is kept and Astro's image pipeline regenerates responsive sizes, so Ghost's
/size/URLs are dropped. - Rewrite references — feature images and in-body images point at local paths;
__GHOST_URL__is replaced with the site root. - External images (e.g. Unsplash) are left remote by default, or localized with
--images localize-external.
--images assets (default) writes to src/assets/blog/ for full Astro optimization and import-based references. --images public writes to public/images/ with stable, unprocessed URLs — use this if external sites hot-link your images.
06Routes & redirects
SEO equity lives in URLs, so Portage preserves Ghost's permalink structure exactly and generates a redirect map for anything that has to change.
| Ghost route | Astro route | Handled by | |
|---|---|---|---|
| /{slug}/ | → | /{slug}/ | Post collection + dynamic route |
| /{page-slug}/ | → | /{page-slug}/ | Pages collection |
| /tag/{slug}/ | → | /tag/{slug}/ | Generated tag pages |
| /author/{slug}/ | → | /author/{slug}/ | Generated author pages |
| /rss/ | → | /rss.xml | @astrojs/rss + redirect |
- Trailing slashes — Ghost uses them, so Portage sets
trailingSlash: 'always'inastro.configto avoid a redirect storm. - Redirect map — emitted as
_redirects(Netlify),vercel.json, or Astroredirectsconfig via--redirects. - Sitemap —
@astrojs/sitemapis added and configured to the new base URL.
07Out of scope
Portage moves content. Ghost is also a membership and email platform, and those systems do not have a one-to-one home in a static Astro site. The following are deliberately not migrated — and Portage tells you what it skipped rather than failing silently.
- Members & subscribers — exported to a separate
members.csvfor portability, but auth, sign-up, and gating are yours to rebuild. - Paid tiers & Stripe — billing relationships cannot be carried; reconnect at your new provider.
- Email newsletters — past issues migrate as posts; sending and delivery do not.
- Native comments — not exported by Ghost; move to a third-party (e.g. Giscus) if needed.
- Theme — Handlebars templates are not converted. You rebuild presentation in Astro (that is the point).
- Integrations, webhooks, API keys, staff accounts — not transferable; code injection is carried into frontmatter for reference but never executed.
Posts with visibility: members or paid are extracted and written with access: members and draft: true by default, so they never publish unintentionally. Decide your gating strategy, then flip them on deliberately.
08Edge cases
- Lexical vs. Mobiledoc — both are handled; if a structured block can't be parsed, Portage falls back to the rendered
htmlfor that node and logs it. - Internal tags — tags whose names start with
#are Ghost-internal; they're stripped from publictagsand preserved underghost.internalTags. - Drafts & scheduled — excluded unless
--include-drafts; when included they carrydraft: true. - Multiple authors — preserved as an array with the primary author first.
- Slug collisions — a post and a page sharing a slug are namespaced by collection; conflicts are reported in the manifest.
- Email-only posts — newsletter issues never shown on web are skipped by default;
--include-email-onlybrings them in. - Custom templates —
custom_templateis mapped to alayouthint rather than dropped. - Cross-references — internal links using
__GHOST_URL__or absolute site URLs are rewritten to relative routes.
09Output
A predictable, buildable Astro project. The manifest at the root is the full ledger of what was extracted, transformed, and written.
astro-project/ ├── src/ │ ├── content/ │ │ ├── blog/ │ │ │ ├── leaving-the-walled-garden.md │ │ │ └── … 141 more │ │ └── pages/ │ │ └── about.md │ ├── assets/blog/ ← 261 localized images │ ├── components/ghost/ ← MDX card stubs (if --content mdx) │ └── content.config.ts ├── public/_redirects ← 12 generated redirects ├── portage.manifest.json ← extract / transform / load ledger └── astro.config.mjs ← trailingSlash · sitemap · redirects
Content collection schema
import { defineCollection, z } from 'astro:content'; import { glob } from 'astro/loaders'; const blog = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), schema: ({ image }) => z.object({ title: z.string(), description: z.string(), pubDate: z.coerce.date(), updatedDate: z.coerce.date().optional(), heroImage: image().optional(), heroImageAlt: z.string().optional(), tags: z.array(z.string()).default([]), authors: z.array(z.string()).default([]), featured: z.boolean().default(false), draft: z.boolean().default(false), canonicalURL: z.string().url().optional(), seo: z.object({ title: z.string().optional(), description: z.string().optional() }).optional(), }), }); export const collections = { blog };
Sample migrated post
--- title: "Leaving the Walled Garden" description: "Why we moved our publication off a hosted platform." pubDate: 2025-11-18 updatedDate: 2026-01-04 heroImage: ../../assets/blog/walled-garden.jpg heroImageAlt: "A weathered lock gate at low tide" tags: ["migration", "astro"] authors: ["Dana Reyes"] featured: true canonicalURL: "https://blog.example.com/leaving-the-walled-garden/" --- The crossing starts at the dock, not the destination…
10CLI
Three stages, run in order. Each is idempotent and writes to the manifest.
$ npx portage extract --from ghost --url $URL --key $KEY --to ./astro-project $ npx portage transform --schema content-collections --content mdx $ npx portage load --images assets --redirects netlify
| Flag | Values | Default | Purpose |
|---|---|---|---|
| --url · --key | string | — | Live Content API source. |
| --export | path | — | JSON export source (alternative to --url). |
| --content | markdown · mdx | markdown | Body format & card handling. |
| --images | assets · public · localize-external | assets | Image placement strategy. |
| --include-drafts | flag | off | Carry drafts as draft: true. |
| --redirects | netlify · vercel · astro | astro | Redirect map format. |
| --dry-run | flag | off | Plan & diff only; write nothing. |
11Verification
Every crossing is auditable. Nothing is written until you've seen what will change.
- Dry-run first —
--dry-runprints the full plan and a diff against the target directory; the filesystem is untouched. - Manifest ledger —
portage.manifest.jsonrecords every source entity, its checksum, the transform applied, and the output path. - Counted on, counted off — extract and load counts are reconciled; any delta (skipped, failed, gated) is listed explicitly.
- Idempotent — re-running over an existing project shows only true changes, so you can re-pull and review before committing.
$ npx portage load --dry-run → 142 posts → 142 files ✓ reconciled → 318 images → 261 unique ✓ deduped → 12 redirects mapped ✓ → 6 member posts gated ⚠ review access: members → 0 unresolved references ✓ nothing left on the dock