From Prismic to Payload: inside our CMS migration

When we decided to move our platform from Prismic to a self-hosted Payload CMS, we thought we were just switching content tools.

It turned out to be much more than that.

This migration has been a ground-up overhaul of not just how we model and manage content, but how we think about velocity, validation, automation, and collaboration between engineering, product, and content teams. It’s an ongoing journey that has already transformed hundreds of domains and reshaped our internal workflows from the ground up.

In this blog, we’re pulling back the curtain on how we approached the migration, the technical systems we built to support it, the missteps and mindset shifts along the way, and what we’d do differently if we had to start over.

Whether you’re planning your own CMS migration, rethinking your content architecture, or just curious about what it takes to move fast without breaking things, this is for you.

  • Quick summary of what the migration was.
  • What is Prismic, and why are we using it
  • What prompted the change (e.g., limitations, cost, performance, control)?

Why Payload CMS?

Before we landed on Payload, we cast a pretty wide net.

We evaluated a bunch of headless CMS platforms — including Strapi, Directus, Hygraph, Sanity, Contentful, Storyblok, Builder, Webiny, and a few others we ditched pretty early. Some were ruled out because they didn’t support localization well (looking at you, Keystone), others lacked basic stuff like a write API, or imposed frustrating limits on content modeling, user roles, or API capabilities.

We had a long wishlist:

  • A modern UI
  • First-class developer experience
  • Write APIs and programmatic content control
  • Field-level localization
  • Component-based content modeling (dynamic zones/slices)
  • Draft/publish, preview, version history
  • Self-hosting support — this was big for us
  • Custom field components and UI extensibility

After an initial round of eliminations, we narrowed it down to four serious contenders: Strapi, Payload, Directus, and Hygraph. We went deeper with each — looked through docs, tried quick POCs, combed through GitHub issues, and stress-tested their admin UIs.

Why not Directus or Hygraph?

Directus had some powerful features like dashboards and automations, but lacked flexibility in key areas like field-level customization and dynamic content modeling. Hygraph was polished and enterprise-friendly but leaned heavily into GraphQL and lacked the extensibility we were after.

That left us with two real contenders: Strapi and Payload.

Strapi vs. Payload

Strapi (v4) was mature, well-documented, and had a big community. It had more community plugins, better guides, and a proven track record. At first, it seemed like the safer bet — and we actually chose it initially.

But Payload quickly started pulling ahead:

  • Customization was smoother and more intuitive.
  • Its admin panel was faster, cleaner, and easier to extend.
  • API responses were more consistent and predictable.
  • Built-in version history and better field-level localization were huge wins.
  • The core team was super responsive — we got proactive help during our experiments.
  • Feature velocity was impressive — Payload was shipping meaningful updates fast.

What sealed the deal was Payload’s roadmap — especially their focus on editor UX (think Gutenberg-style editing), deeper TypeScript support, and out-of-the-box features that didn’t require hunting for community plugins.

Yes, it was newer (v1 had just dropped in late 2022), and we knew we’d be on the frontier. But the trade-off felt worth it — more control, better performance, and a developer-first platform we could mold to fit our architecture instead of fighting against.

The challenges with Prismic

When we first picked Prismic, it felt like the right decision. It had the features we needed, was API-first, and supported internationalization. But as our product and team evolved, so did our needs, and Prismic started showing its limitations.

⚠️ No required fields or field-level validation

One of our earliest pain points was the lack of control over which fields were required. There was no native way to enforce mandatory fields or validate inputs. As a result, content teams would occasionally miss entering critical information — a small oversight that could take down a page or cause a 404/500 error in production.

We routinely found ourselves debugging bugs that turned out to be a missing UID or a typo in a link field. These weren’t exotic edge cases — they were happening weekly. On average, we saw 8–10 MB-related bugs a week, and 3–5 of those could be directly traced back to missing or malformed content in Prismic.

Developers were regularly pinged for quick help — "Hey, can you check why this page isn’t loading?" — which meant ad hoc context-switching and wasted hours.

🌐 Localization was all-or-nothing

We support localized versions of many pages, but not all fields need translation. For example, product logos or certain structured components should remain consistent across languages.

Unfortunately, Prismic didn’t let us configure localization at the field level. Every field was either localized or not. So, we had to build our own workaround: fetch both the English and locale documents, then perform data transformation on the fly. This added complexity to both our rendering logic and our backend.

Over time, the performance cost added up — we were spending 300–700ms just on fetching and merging locale-specific content for each page.

🛑 No write API

You’d expect a modern CMS to let you programmatically update content, especially when you're managing 30,000+ documents. But Prismic had no write API.

This made bulk changes painful. We had to rely on the import/export feature, which was clunky, rate-limited, and frankly not built for scale. Need to update a copyright notice or fix 500+ SEO meta titles? That meant writing transformation scripts, exporting data, massaging JSON, and manually uploading it all back into Prismic.

Each of these seemingly “simple” operations would take anywhere from one to three weeks, depending on complexity. It wasn’t sustainable.

📦 Nested components — or the lack of them

If your CMS doesn’t support nested fields, you get creative… and by creative, I mean hacky. We had to simulate nesting using dummy “wrapper” components like <{slice-name}-start> and <{slice-name}-end>. It worked — kind of — but was error-prone and a nightmare to debug.

Imagine a content writer forgetting to close a wrapper — half the page wouldn’t render. And because Prismic didn’t allow collapsing long components, managing deeply nested layouts was visually overwhelming. On the frontend, we had to write transformers to interpret and flatten this pseudo-hierarchy before rendering.

Some of our components went four levels deep — Background → Tab → Tab Content → Accordion. Yes, it was as painful as it sounds.

🔁 Import/Export limitations

To be fair, Prismic’s import/export saved us more than once, but it came with heavy restrictions. You could only import 200 docs at a time, with hard caps on ZIP size and file weight. More importantly, all relationships between documents were lost during import.

Imagine re-adding internal links to every document after a bulk import — we had 3+ relationships per page. You do the math.

🔎 API querying limitations

We also hit a wall with Prismic’s querying capabilities. While it supported filtering on some basic fields, it didn’t allow filtering within slices or integration fields, which is where most of our structured data lived.

For example, to build a city-level collection page, we had to hardcode a list of city names in a dropdown and rely on editors to manually tag them correctly. This meant that even a small typo (e.g., "Newyork" vs "New York") would cause documents to disappear from our listings.

Fetching related content meant making multiple API calls — find the parent, then query all linked docs manually. It worked, but wasn’t efficient, and we knew it wouldn’t scale.

🏷️ Tag chaos

Tags in Prismic were essentially a free-for-all. Anyone could create them. No dashboards to view all tags. No way to edit or delete a tag unless you manually remove it from every single document where it was used. Predictably, this led to duplicates and inconsistencies.

We used tags to power things like sitemap generation and page-domain associations, so incorrect tagging meant broken or missing SEO-critical pages. Not ideal.

👥 User limits and shared accounts

Last but not least, we hit user limits. The only solution was to upgrade to a higher plan, which didn’t offer value for the price. As a result, teams were sharing logins, making it impossible to audit who changed what and when. Debugging content issues became detective work.


That’s just scratching the surface, but these limitations — coupled with our growing scale — made it clear that we had outgrown Prismic. The CMS had served us well for a time, but it was holding us back from iterating faster, scaling efficiently, and giving non-devs real autonomy.

Designing the migration

Before writing a single line of transformation code, we had to rethink how our pages were structured. What started as a 1:1 migration from Prismic to Payload quickly evolved into a complete redesign of our content model.

We began by analyzing the existing Prismic data structures — slice types, localization strategy, rich text formats — and mapping them to their Payload equivalents. Payload’s schema-first approach forced us to be deliberate, which turned out to be a good thing. Our old models were flexible, but often bloated. This migration gave us the excuse we needed to strip down, simplify, and make our models leaner and more scalable.

One of the trickier challenges was migrating rich text content. Prismic uses its own rich text format, whereas Payload supports Lexical. We built a custom transformation layer to handle this:

  1. Convert Prismic rich text → Markdown
  2. Then convert Markdown → Lexical JSON

This gave us more control and let us preserve formatting across edge cases like embedded links, images, and nested blocks.

While the data mapping work was happening, we were also building the foundation of our migration workflow.

Migration execution

We designed the migration to run in batches, starting with microsites (MBs) that had lower GBV and gradually moving toward higher-value, higher-risk domains. This let us iron out edge cases early and build confidence before touching business-critical content.

Each batch included 10 MBs, and depending on complexity, the full migration cycle — transformation, validation, sanity checks, and go-live — typically took 15 days.

But what really made this sustainable wasn’t just smart sequencing — it was the tooling and infrastructure we built around it.

🚛 Infrastructure overview

  • S3 was used to cache Prismic data via daily cron jobs (Lambda-based), reducing API throttling and making reruns cheap and predictable.
  • Payload CMS acted as the orchestration hub — exposing internal APIs to trigger and control each step of the migration.
  • AWS SQS powered our parallel migration engine. We had two separate queues:
    • A Batch Migration Queue (domain-level): to orchestrate full MBs and their dependent pages.
    • Migration Job Queue (page-level): to run atomic operations like migrating SEO, linking parents, or transforming content blocks.
  • We even built a Migration Dashboard inside Payload, showing:
    • Status of every document (pending, partial, complete, failed).
    • Retry buttons for specific steps.
    • Migration logs and summaries at the domain and page levels.

This wasn't just a script. It was a system that could rerun, track errors, retry only failed parts, and safely operate at scale.

🧪 Testing & validation

We didn’t trust green checkmarks — we built multiple layers of validation into the process.

  1. Pre-migration checks: Before running any migration, we verified that the page existed in Prismic, didn’t have invalid links, and didn’t conflict with any existing Payload doc. UIDs, locales, and domain linkages were all cross-checked.
  2. Content-level validation: Once a page was migrated, we validated whether:
    • All expected sections were present.
    • Product cards (critical for booking flows) were ordered correctly.
    • Link counts and embedded references matched.
    • Textual differences stayed within expected tolerances.
  3. Visual Difference (VD): This was our final gatekeeper.We built an internal Visual Diff tool that took screenshots of both the Prismic and Payload versions of a page — mobile and desktop — and ran a pixel-to-pixel comparison.It wasn’t just about screenshots. The tool simulated user flows like scrolling, clicking, collapsing accordions, etc., and flagged discrepancies in layout, spacing, or broken functionality.We’ll cover the full VD tool in an upcoming post, but here’s the short version: it saved us from shipping a lot of invisible regressions.
  4. Sanity checks: Pre-live and post-live sanity checks made sure all pages loaded correctly from end to end — across all locales — before traffic was routed to the new stack.
  5. Load Testing: Early migrations (low-footprint MBs) went smoothly. But as we scaled up, the cracks started to show — degraded response times, spiked DB queries, and dropped API calls.We had to hit pause, optimize the Payload service and database queries, and only then resume migrations.

⚒️ Workflow in practice

Each migration followed a repeatable, tracked process:

  1. UID mapping: We fetched UIDs (Unique IDs for pages) from Prismic (with locale awareness) and stored them in a dedicated collection inside Payload.
  2. Base document creation: Dummy documents were created first — this helped link domains, route correctly, and act as placeholders for real content.
  3. Entity-wise migration: Tasks like migrating headers, footers, SEO, categorization, banners, etc., were executed in isolation, tracked independently, and could be retried individually.
  4. Error handling & monitoring: Every task logged granular error info. Failed jobs were queued for inspection. We also had Slack notifications and UI views to track issues as they happened.
  5. Pre-flight checks: Validation included comparing content structure, section ordering, link counts, and more.
  6. Visual QA: Our Visual Difference tool ran a pixel-by-pixel diff across mobile and desktop, scored visual and content changes, and flagged regressions.
  7. Post-validation: Translations were queued, a sanity suite was executed, and finally, live traffic was flipped with another post-live sanity pass.

This process helped us run hundreds of concurrent migrations while keeping failure rates low and observability high.

Post-migration flow

Once a page passed visual and content checks, we pushed it through our localization pipeline for multi-language rollout. This process relied on our internal translation service and maintained consistent block structures across locales.

After translations were complete, we ran another round of multi-locale sanity checks, and only then did we flip live traffic for that MB.

A post-live sanity run followed immediately to confirm everything was holding steady in production.

Routing to the new CMS

Once a domain had passed all validation, visual checks, and sanity testing, the final step was routing live traffic to the new CMS — internally known as Kirby.

We started with a manual, infra-heavy setup. Each domain needed its own CloudFront distribution, configured to point to Kirby (Frontend to new CMS)’s ALB as the origin. This meant setting HTTP methods, cache policies, and invalidation rules for every single MB, one by one. We also had to update Istio routing via Helm templates and manage changes across multiple services.

It worked — but it didn’t scale.

🧱 Scaling the routing strategy

Eventually, the infra team rolled out a mesh-native approach using Istio VirtualService routing backed by Helm templating. This changed everything.

Instead of provisioning a new CDN per domain, we:

  • Reused a shared CloudFront distribution, avoiding duplication and centralizing cache control.
  • Added per-domain Host-header–based routing directly inside Mystique’s Istio config.
  • Managed allowed methods and cache invalidation centrally.
  • Maintained a list of Kirby-bound domains in Mystique’s Helm chart, making routing self-serve.

Developers could now add a new domain to be routed by simply raising a PR — no infra dependency, no custom setup. The Helm chart automatically generated the needed routing rules using conditionals and loops.

Example Istio rule (auto-generated):


- match:
    - headers:
        host:
          exact: www.bangkok-floating-market-tour.com
  name: kirby-www-bangkok-floating-market-tour-com-route
  route:
    - destination:
        host: headout-kirby.headout-kirby.svc.cluster.local
        port:
          number: 80

This shift massively improved deployment velocity, reduced operational load, and made the final leg of the migration nearly frictionless.

Automation & DevX improvements

While the early days of migration were manual, time-consuming, and fragile, we gradually evolved our stack into something far more robust and developer-friendly.

What started as a set of shell scripts and bulk API triggers grew into a suite of internal tools, queues, dashboards, and developer UX improvements that made migrations scalable, observable, and repeatable.

🧱 From manual to automated

We moved from ad hoc API calls to a two-queue SQS system:

  • Batch migration queue handled domain-level orchestration.
  • Migration job queue executed atomic tasks — migrating SEO, content blocks, layout data, etc.

Each page-level task had clear ownership, retry support, and full error logging in MongoDB. This meant we could pick up failed jobs, rerun specific stages, and debug with full visibility.

We also introduced:

  • A Migration dashboard inside Payload, showing domain/page/task statuses.
  • Built-in retry buttons for failed tasks.
  • Auto-publishing support once the domain migration passed basic validation.

🔁 Automating the repetitive stuff

As the system matured, we layered in automation around the most painful manual workflows:

🚀 1. Automated bulk translations

Previously, translators manually copied content from Prismic, ran it through a translation tool, and pasted it back — slow, error-prone, and impossible to scale.

Now, with Payload:

  • Translations are auto-triggered on publish.
  • Payload’s block consistency across locales ensures parity.
  • Result: we’ve gone from ad hoc translation to ~1000 pages/day across 6+ languages — fully automated and always in sync with the English version.

🏷️ 2. Automated media tagging & tracking

In Prismic, media tagging was completely manual. Teams had to re-upload the same asset to the DAM and maintain its metadata by hand.

Now, with deeper Payload integration:

  • Media assets are automatically tracked.
  • Tags, usage data, and metadata are logged programmatically.
  • No duplication, no context-switching — just clean, structured asset management.

🧠 3. Custom workflows & UI integration

We integrated internal APIs directly into Payload:

  • Real-time page validation on edit.
  • Dynamic UI helpers that guide editors and reduce data entry errors.
  • Smart templates: If a user adds a categorization, the CMS can scaffold an entire page structure with pre-defined blocks. This has saved hours for the content team, especially on long-form or deeply structured pages.

🧪 Smarter QA and validation

We evolved from manually checking each migration to a system with:

  • Pre-flight content checks (UIDs, links, data presence).
  • A custom Visual Difference Tool for pixel-by-pixel diffing.
  • Auto-triggered sanity suites (pre and post live).

We also experimented with relaxing visual diff constraints for less-critical areas while still enforcing integrity on primary blocks like product cards, booking widgets, and CTAs.

Performance improvements

While we did observe some API and tooling optimizations during the process, performance gains were never the primary goal of this migration.

Our focus was moving from a third-party CMS to a self-hosted, extensible platform — and while there are anecdotal improvements, we don’t yet have a fair apples-to-apples comparison to share.

Outcomes

This migration wasn’t just about changing CMS providers. It changed the way we operate across tech, content, and product workflows.

✅ Project outcomes

  • 200+ domains successfully migrated (till date), with 300 more on the way.
  • Prismic is no longer blocking new features.
  • Payload is now our primary CMS — a platform we control, extend, and iterate on.

🧑‍💻 Developer & infra wins

  • Migration is fully scriptable and observable via SQS and Mongo.
  • Routing is self-service — no more 15-minute handovers to Infra. Add a domain to the Helm chart, raise a PR, done.
  • Visual QA and validation are predictable and repeatable.

💬 Editorial & Product wins

  • Bulk translations went from backlog to baseline — ~1000 pages/day across six languages, fully automated.
  • Page parity and localization gaps closed — block consistency ensures every locale has the same content flow.
  • Product and marketing teams now have faster go-to-market — no more dependency on devs for page scaffolding or media handling.
  • Templated page creation allows editors to scaffold complex pages in minutes, not hours.

📌 Strategic wins

  • We now have clean, typed schemas with rich validation rules.
  • All new content follows our latest best practices.
  • The CMS is integrated with our ecosystem, enabling smart workflows, automation, and tighter feedback loops.

What we learned (and what we’d do differently)

If there’s one core takeaway from this entire migration, it’s this:

Progress > Perfection.

💡 The cost of perfection

In the early stages of our CMS migration, we were laser-focused on achieving a pixel-perfect finish and ensuring 100% content validity for each domain. Every section, every block, every link had to pass strict validation.

It sounded ideal on paper — until it started blocking real progress.

We were burning time on cleanup edge cases, chasing bugs that had little user impact, and delaying entire batches because one document had a field mismatch. At that rate, the full migration would’ve taken another year — and in the meantime, product teams were blocked, new CMS features sat unused, and the cost of maintaining two systems kept going up.

So, we changed course.

We made a deliberate, strategic decision to prioritize momentum:

  • We began migrating domains in their current state, skipping certain validations and cleanups.
  • We enforced strict standards for new content only — everything created or edited on Payload had to comply.
  • Post-routing cleanups were planned separately, and invalid domains wouldn't receive updates until cleaned.

This shift was a game-changer. Velocity improved dramatically. Product teams were unblocked. We migrated 200+ domains and started building on the new system — all without waiting for the elusive “perfect migration.”


⚔️ Picking our battles

To be clear — we weren’t anti-quality. We improved a lot:

  • Cleaned and normalized our data structures
  • Re-architected our block model
  • Adopted Lexical rich text
  • Refined our querying strategy
  • Introduced structured testing and visual QA flows

But we learned that not everything needs to be perfect at once. Some things are negotiable — others aren't.

For example:

  • ✅ Missing alt text on a rarely used field? Add a fallback or placeholder.
  • ❌ Redesigning how we model block layouts for flexibility and future features? That stays.

The key is knowing what’s worth fighting for during migration — and what can be fixed later.


🛠️ What we’d do differently

If we were to run this migration again, here’s what we’d change:

1. Automate sooner

A lot of tasks started manual, and only got automated after they began hurting. That cost us dev bandwidth we could’ve used better.

  • Automated Prismic syncs to our intermediate store (S3)
  • Smarter visual diffing, where critical sections were strictly verified but less-critical parts were relaxed
  • Retry logic for failed migration tasks
  • Error report generation for clean follow-ups
  • Self-serve routing tools — routing a domain from Prismic to Kirby used to take 15–20 mins of dev time each instance. That adds up fast.

2. Define rules early

We spent time arguing whether X was a blocker, or Y needed to be fixed. We should’ve codified this early:

  • What’s a migration blocker vs. what’s a post-migration cleanup?
  • Which fields are required vs. nice-to-have?
  • What can be visually off by 2px vs. what’s considered broken?

3. Bake in cleanup plans

We realized too late that perfection can be incremental. If we had a clearer post-migration cleanup workflow and ownership structure, we would’ve felt more confident about skipping things during the main migration flow.

Conclusion

CMS migrations are never just about content.

They're about control over your schemas, your workflows, your velocity, and your future roadmap. For us, moving from Prismic to Payload wasn’t just a technical upgrade — it was a cultural shift. We went from working around someone else’s constraints to building with our own rules.

We learned to prioritize progress over perfection, to invest early in automation, and to build tooling that puts power back into the hands of our teams — devs, editors, and product alike.

There’s still more to do. More automation to add. More features to unlock. But now, we’re finally on a platform that lets us move fast, build confidently, and grow without compromise.

And that’s exactly where we wanted to be.

Vibhor Chaturvedi’s Profile Image

written by Vibhor Chaturvedi

Lead Software Engineer who loves clean architecture, fast APIs, and tools that scale. I fix messy migrations, chase better workflows, and balance it all with lifting sessions and a mild obsession with fitness routines.

Dive into more stories