Migrating from Ghost to Sanity without breaking everything

Migrating from Ghost to Sanity without breaking everything

A practical look at when Ghost hits its limits, why teams move to Sanity, and what the migration actually involves — based on our CoinTracker project.


If you needed to spin up a blog quickly at any point in the last few years, there's a very high chance you reached for Ghost. It's fast, clean, especially for writing and publishing, it does exactly what it says on the tin. The problems usually start when the site stops being just a blog. Suddenly, you need structured content, different content types, reusable sections, and a headless frontend that doesn't feel like it's negotiating with your CMS every time you ship a page.

We've seen this happen on a few projects, including a migration we worked on for CoinTracker. This post is not a Ghost vs Sanity rant but a straightforward look at when Ghost starts to hit its limits, why teams move to Sanity, and what that migration actually involves once you decide to stop pretending your blog is still just a blog.

No shade, Ghost is fine

Before we start throwing it under the bus, Ghost is genuinely excellent at the thing it was designed to do.

If you need a blog, a publication, or a newsletter setup, it's hard to beat. You spin it up, pick a theme, start writing, and you're live. The editor is clean, publishing is also fast, and SEO basics are handled. For a single content stream, it stays out of your way, which is exactly what most teams want in the early days, but it is kind of the point.

Ghost is a publishing tool first. As long as your world fits neatly into that shape, it works really well. The moment you start needing content that doesn't behave like a blog post, that's when you start noticing the edges.

Where Ghost breaks

It's not built for structured content modeling. You don't get custom schemas, reusable content blocks, or visual editing for things like landing pages, product storytelling, or multi-section marketing pages. Everything starts getting squeezed into the same post-shaped box. Headless CMS support is there, but it's not exactly native. If you're trying to run a proper React Router setup, the APIs can feel rigid, and real-time collaboration isn't really part of the story. It works, but it's not what the system was designed around.

Customization also has a ceiling. Theme work means getting comfortable with Handlebars and HTML, and once you move beyond basic tweaks, you're writing more custom code than you expected. There's no schema-as-code approach, so you're not really modeling content. You're styling it. At scale, things get a bit more fragile. Self-hosted setups can get twitchy around updates, caching dynamic content takes effort, and the ecosystem is smaller, which means fewer ready-made solutions when you hit a wall.

And if you're thinking about e-commerce, that's where it really shows its focus. There's no native Shopify sync, no advanced querying layer, and the product is still very much centered around publishing and newsletters, not omnichannel content. But we have to say, none of this makes Ghost bad. It just means it's very good at one job, and we saw the same issue when we were working on CoinTracker's website.

CoinTracker Ghost breakdown

At CoinTracker, blogs were the core product surface and not just a side marketing effort. It consists of updates, tax guidance, education, and integrations, which are read by millions of users. For a long time, they were fine with Ghost, but when the blog grew to 200+ posts, 20+ authors, 30+ tags, 200+ integration guides, and over 100 learn pages spread across different systems, they were no longer fine with Ghost. The blog lived in Ghost, other content lived elsewhere, and keeping things in sync slowly became a part-time job nobody signed up for.

The bigger problem was structure. CoinTracker needed:

  • Call-to-action sections inside articles
  • Highlight blocks for important notes
  • Integration content that didn't look like a blog post
  • Rich text that needed some shape to it

Editors felt it too. Previews were also limited, validation was minimal, and anything slightly custom meant pulling in developers. Not the end of the world, just consistently inconvenient. Nothing broke overnight. The site didn't crash. Ghost didn't fail. It just slowly became obvious that what started as a publishing setup had turned into a full content platform, and it was still being treated like a blog.

That was where it made sense to move to something built for structure, not just publishing.

Why Sanity made sense

Trying to model all of it inside a flat post editor started to feel like storing your entire codebase in a single index.js file and telling everyone you'll "clean it up later." It works for a bit. Then it becomes a personality trait. Once it was clear we needed a more structured CMS approach, Sanity was the obvious move.

With Sanity, we could finally stop pretending everything was a "post" and actually model content properly:

  • Define different post types that aren't just posts.
  • Create reusable sections that editors can drop into pages without the need for a developer.
  • Design a single place where blogs, learn content, integrations, authors, and tags live together

Instead of forcing everything into a post, we could define schemas for exactly what we needed, that is, blogs, guides, authors, and integration pages. Each with their own fields, validation, and layout blocks. Second, we created one content layer. Before, content was spread across systems. After the move, everything lived in one place.

Sanity is built to sit behind any content delivery stack. Queries are flexible, previews are proper, and collaboration is real-time. We were not trying to bend a publishing tool into being an API. Its quieter benefits are what always push us for any migration. You have seen our other projects (if not, just check our case studies).

  • Schema-as-code with TypeScript support.
  • Validation at the content level, not just the UI.
  • Reusable blocks for CTAs, highlights, videos, and tables.
  • Editors can move faster without filing tickets for every layout tweak.

Migration scope of CoinTracker

When people hear "Ghost to Sanity migration," they usually picture a quick export/import job. This wasn't that. We weren't just moving a blog. We were consolidating an entire content layer that had grown across multiple systems:

  • 100+ blog posts from Ghost
  • 20+ authors
  • 30+ tags
  • 200+ integration guides from internal APIs
  • 100+ learn pages from a separate Astro setup

Each content type had its own source, format, and edge cases. Some came from the Ghost API, some from internal services, some from static HTML. None of it lined up neatly. So the job wasn't just migrating content. It was pulling everything into one structured system, without breaking URLs, losing media, or upsetting SEO. The usual fun.

The architecture we set up for migration

Before, it looked something like this:

  • Ghost handled the blog
  • Learn pages lived somewhere else
  • Integration content came from internal APIs
  • Frontend had to stitch everything together manually

It worked, but it wasn't exactly clean. Every new content type meant another system, another integration, and another thing to remember when something didn't update properly. After the move, the setup became a lot more sensible:

  • Sanity became the content layer
  • React Router handled the frontend
  • Internal APIs continued to power product-specific data

Everything had a clear role. Sanity stored and structured the content. React Router (v7 in framework mode, basically the Remix successor) handled rendering with server loaders pulling content directly from the CMS, plus SSR on Cloudflare Workers. APIs handled the dynamic bits. In simple terms, we stopped treating the CMS like a website and started treating it like a proper content backend. Once that clicked, everything became a lot more composable.

How we actually did the migration

This wasn't a lift-and-shift. Ghost stores content as HTML blobs. Sanity expects structured content. So the job was less "copy paste" and more "carefully take this apart and rebuild it without losing anything important." We split the migration into scripts per content type, all written in TypeScript and sharing a common utility layer. Each script followed the same pattern:

  1. Fetch from the source (Ghost API, internal APIs, or scraped HTML)
  2. Transform it into the Sanity schema
  3. Upload assets (images, OG images, author photos)
  4. Write everything in batches into Sanity

Simple in theory. Slightly less simple once you start doing it hundreds of times.

The HTML problem

Ghost stores posts as HTML. Sanity uses Portable Text (structured JSON). If you convert HTML naively, you lose meaning. Tables turn into messes. Images become random <img> tags. Videos disappear into the void. So we built a conversion pipeline using @portabletext/block-tools that understood Ghost's markup and mapped it to proper Sanity blocks:

  • Image cards: image blocks with asset references. Just an FYI Ghost's image cards come wrapped in specific markup:

    <figure class="kg-card kg-image-card">
      <img class="kg-image" src="..." alt="..." loading="lazy" />
      <figcaption>Optional caption text</figcaption>
    </figure>
    
  • YouTube embeds: Video embeds were appearing as iframes inside container divs:

    <div class="video-container fluid-width-video-container">
      <iframe src="https://www.youtube.com/embed/VIDEO_ID"></iframe>
    </div>
    
  • Tables: proper row/cell data

  • Random divider elements: actual divider blocks

We also filtered out empty paragraphs, which Ghost happily generates and nobody asked for.

Images needed a second pass

Feature images were easy. The Ghost API gives you those directly. Inline images were not. Those only exist in the rendered HTML, not cleanly in the API. So we fetched the live Ghost page, parsed it with Cheerio, extracted every inline image, uploaded them to Sanity first, built a map of old URL -> new asset reference and then ran the HTML conversion using that map.

Two passes, but it meant every image became a proper Sanity asset instead of a broken link.

Batching so nothing explodes

Migrating hundreds of documents means a lot of network calls. And networks fail. Often at the worst time. So we built in some guardrails:

  • Batched writes (5-10 items at a time)
  • Transactions per batch
  • Retries with backoff
  • Promise.allSettled so one failed image didn't kill an entire post

URL consistency

Ghost and Sanity don't share URL structures, so we defined a clear mapping:

  • Blog: /blog/[slug]
  • Author: /author/[slug]
  • Learn: /learn/[slug]
  • Integrations: /integrations/[slug]

That made reverse lookups easy and kept links intact. We rebuilt the entire system into something structured, asset by asset, block by block, without breaking pages, images, or URLs. Which is exactly as fun as it sounds.

What we did differently in this migration

We could've treated this as a straight 1:1 migration. Move the posts, keep the structure, and call it done. It would've been faster, and also a complete waste of the opportunity. Instead, we treated it as a reset. Before writing any scripts, we designed the Sanity schemas around what the content needed to become, not what Ghost happened to give us. That meant adding proper structure, field-level validation, and cleaner content models so we didn't just carry old limitations into a new system.

We also built every script to run in dry-run mode by default. It would show exactly what was going to change before committing to anything. That alone saved us from pushing bad data more than once. Because no migration is perfect on the first pass, everything was designed to be rerun safely. Idempotent writes, targeted fixes, and the ability to reprocess content without tearing the whole thing down again.

Ghost vs Sanity

As we have said before, Ghost is great at being a blog engine. That's not an insult, if your site is mostly posts, newsletters, and the occasional landing page. Sanity is built for the second phase. Structured schemas, flexible queries, real-time collaboration, and native headless delivery mean you're not trying to force a publishing tool to act like a content platform. You just build the content model you need and move on with your life.

Performance and scale are also where the difference shows up. Sanity's API responses are typically sub-150ms globally and auto-scale. Ghost performance depends heavily on how it's hosted and tuned. Self-host it badly, and you'll discover new and exciting ways to wake up at 2am and shell out more money.

CategoryGhostSanity
Core modelMonolithic CMS for blogs/newslettersHeadless CMS for structured content
Content flexibilityPosts, tags, basic pagesUnlimited schemas, relations, custom types
Headless supportPossible, but rigidBuilt for Next.js and headless from day one
Editor experienceClean writing interfaceReal-time preview + structured editing
PerformanceDepends on hosting and setupGlobally distributed, auto-scaling APIs
CollaborationBasic workflowsReal-time multi-user editing
E-commerce/content syncManual workaroundsNative integrations (e.g., Shopify)
Dev experienceHandlebars themes, limited APIsSchema-as-code, GROQ, TypeScript-friendly
ScalingNeeds manual tuningScales automatically
PricingCheaper early onUsage-based, grows with you

Closing thoughts

If you're outgrowing Ghost, moving toward a headless stack, or trying to consolidate content spread across five different systems, that's exactly the kind of problem we spend most of our time solving. We help with migrations, schema design, and setting up headless architectures that don't fall apart six months later.

Contact us now.

Frequently asked questions

About the Authors

Hrithik
Hrithik

Senior Full-stack Developer

Senior Full-stack Developer with expertise in React, Next.js, and Sanity CMS. Loves building performant web applications and sharing knowledge through technical content.

Sne Tripathi
Sne Tripathi

Account Executive

Account Executive at Roboto Studio, bridging the gap between client needs and technical solutions. Ensures every project delivers real business value.

Jono Alford

Founder of Roboto Studio, specializing in headless CMS implementations with Sanity and Next.js. Passionate about building exceptional editorial experiences and helping teams ship faster.

Aarti Nair
Aarti Nair

Content and Brand Manager

Content and Brand Manager shaping Roboto Studio's voice and narrative. Passionate about storytelling that connects with audiences and drives engagement.

Get in touch

Book a meeting with us to discuss how we can help or fill out a form to get in touch