Sanity vs WordPress Headless (WPGraphQL): A Migration-First Compare
Your WordPress site started as a blog and metastasized into a content platform nobody can safely refactor.
Your WordPress site started as a blog and metastasized into a content platform nobody can safely refactor. The post table is a soup of serialized meta, your "structured" fields live in ACF JSON that only renders correctly through the right theme, and the moment you bolt WPGraphQL onto the front the cracks show: nested ACF flexible content resolves into deeply inconsistent shapes, plugin updates rename fields out from under your queries, and every editor still expects the wp-admin they have used for a decade. The migration nobody wants to start is the one everyone needs.
Sanity is the Content Operating System for the AI era, an intelligent backend built so your content model, not your theme, is the source of truth. The distinction matters most during a migration, because WordPress headless keeps the legacy publishing assumptions even after you put a GraphQL layer in front of them, while a move to Sanity is a chance to model the business properly once.
This is a migration-first comparison. We will weigh WordPress headless with WPGraphQL against Sanity (and against Strapi and Contentful as the platforms you are likely shortlisting) across data modeling, query ergonomics, the editor, operations, and the lock-in math, so you can decide what to lift, what to leave, and what to rebuild.
Where WordPress headless actually breaks
WordPress headless is the same monolith with a GraphQL adapter in front. WPGraphQL is genuinely good software, but it is exposing a data model that was designed for a theme to render, not for an API to consume. The symptoms are predictable. Post content arrives as a single HTML blob, so any frontend that wants structured rendering has to parse and sanitize markup at runtime. Custom fields live in ACF, and ACF flexible content layouts resolve into union types whose shape depends on which layout an editor picked, which means your queries fan out into fragments that break whenever a plugin updates. Taxonomies, menus, and options pages each come through a different resolver with different pagination semantics.
The deeper issue is that the database stays authoritative. Postmeta is an unindexed key-value table; once you have a few hundred thousand rows, the queries that power your GraphQL resolvers get slow, and you cannot fix it at the API layer because the storage shape is the problem. You end up running caching plugins, object caches, and a CDN in front of an architecture that was never meant to answer arbitrary content queries.
This maps directly to the first Sanity pillar: model your business. Instead of inheriting the post-and-meta shape, you define content types as portable schemas. The point of starting a migration here is that you get to decide what your content actually is before you decide how to serve it, rather than reverse-engineering intent out of a decade of accumulated plugins and theme logic.
Data modeling: schemas you ship versus fields you bolt on
In WordPress, structure is additive. You start with posts and pages, then ACF adds field groups, then a plugin adds a custom post type, then someone adds postmeta directly. There is no single artifact that describes your content model; it is distributed across the database, the active theme, and whichever plugins are installed. Migrating means archaeology: enumerate every field group, every custom post type, every shortcode, and every place a template reads meta directly.
Sanity inverts this. Your content model is code. You declare types with `defineType` in your schema, version them in git, and review changes in pull requests like any other part of the system. Fields are typed, references are first-class (not a serialized post ID in a meta row), and the schema is the contract the whole team builds against. Because the schema is portable, the same definitions that drive Sanity Studio also generate your TypeScript types through TypeGen, so a field rename surfaces as a compile error in your frontend instead of a silently empty GraphQL response in production.
For a migration this changes the sequencing. You model the target shape first, write a transform that reads the WordPress REST or GraphQL output and maps it into your new types, and import into a dataset you can throw away and rebuild as many times as you need. Rich text becomes Portable Text rather than an HTML string, which means the annotations, marks, and embedded references survive as structured data instead of as fragile markup your frontend has to re-parse on every render.
Query ergonomics: WPGraphQL fragments versus one GROQ projection
WPGraphQL gives you a typed schema and a single endpoint, which is a real improvement over the sprawl of the WordPress REST API. But the queries inherit the underlying model. Pulling a page with its hero, its author, that author's other posts, and a related-articles block means stitching together connections, fragments for each ACF layout, and often multiple round trips because some relationships are not exposed as GraphQL connections at all. Over-fetching is common because GraphQL returns the fields you ask for but the resolver underneath may still hit postmeta repeatedly.
GROQ takes a different approach: you describe the exact shape you want and get it back in one round trip. A single query can filter documents, follow references with the `->` operator, project nested objects, and reshape arrays inline. You can join an article to its author, pull that author's three most recent other articles, resolve image assets, and flatten the whole thing into the JSON your component expects, without fragments and without a second request. Operators like `match()` for text and `score()` for relevance ranking live in the same query language, so search-style retrieval is not a separate subsystem.
This is the second pillar in practice: power anything. The frontend asks for precisely the data a view needs, the Content Lake answers it with schema-aware queries against a store built for exactly this, and you stop maintaining the caching scaffolding that WordPress headless requires to stay responsive under real query load.
The editor: wp-admin habits versus a Studio you build
Editor experience is where headless migrations quietly fail. Your authors have a decade of wp-admin muscle memory. Tell them their familiar editor is gone and replaced by a generic field form on someone else's hosted UI, and you get revolt or, worse, shadow workflows where content gets drafted in Google Docs and pasted in. The editor is not a detail; it is the adoption surface.
This is the clearest line between Sanity and the API-first crowd. Contentful, Strapi, and most peers ship a fixed editing interface you configure. Sanity Studio is a React application you build and deploy. You write custom input components in `sanity.config.ts`, you shape the editing experience with Structure Builder so authors see their content organized the way they think about it, and you can embed validation, custom previews, and even small apps directly in the editor through the App SDK. The Presentation Tool plus Visual Editing stitches the Studio to your live frontend, so an editor clicks an element in a rendered preview and lands on the exact field, which is the closest thing to the in-context editing WordPress users expect, without giving up the headless architecture.
For migration this matters because you can rebuild the parts of the WordPress experience your editors actually valued (inline preview, a structured authoring flow, sensible defaults) as deliberate product decisions in the Studio, rather than accepting whatever a hosted editor happens to offer. Content Releases and scheduling then give you governed, reviewable publishing that the wp-admin plus a calendar plugin never really delivered.
Operations, governance, and the things that page you at 2am
On WordPress headless you are operating two systems: the WordPress backend (PHP runtime, MySQL, the plugin supply chain, security patching) and your frontend. The plugin model is the operational liability. WPGraphQL, ACF, caching, and security plugins all version independently, and a single update can change a field shape or open a vulnerability. You own uptime, scaling the database under query load, and the patch cadence for everything in the stack.
Sanity runs the content backend as managed infrastructure. The Content Lake, the APIs, and hosting are operated for you, which removes the MySQL-under-load and plugin-CVE class of incidents entirely. On the governance side, the platform provides SOC 2 Type II, GDPR compliance, regional hosting and data residency options, and a published sub-processor list, so the security review that a self-hosted WordPress stack turns into a bespoke audit is largely answered by documentation. Roles and Permissions, Audit logs, and Content Releases give you the access control and change history that on WordPress depend on which plugins you trust.
There is a real automation story here too, mapping to the automate-everything pillar. Functions run serverless logic against content events (translation, moderation, enrichment, downstream syncs) without you standing up and maintaining a separate worker fleet, and the App SDK lets you build the internal tooling that on WordPress would be yet another plugin to patch. The net operational shift is from owning a runtime to consuming a service with the audit trail already attached.
Cost, lock-in, and the honest migration math
The lock-in conversation usually starts backwards. WordPress feels low-lock-in because it is open source and self-hostable, and the data is in MySQL you control. In practice the lock-in is in the plugin ecosystem and the theme: your content's meaning is encoded in ACF configurations and template logic, and that knowledge does not export cleanly. Moving off WordPress is hard precisely because the model is implicit.
Sanity's anti-lock-in argument is the explicit, portable model. Your schemas are code in your repository, your rich text is Portable Text (a documented, structured format rather than vendor HTML), and the entire dataset exports to NDJSON through the CLI whenever you want it. You are not trapped by an undocumented database shape; you hold a typed description of your content and a clean export of the content itself. That is a meaningfully different exit position than a WordPress install whose behavior depends on thirty active plugins.
On cost, compare totals, not sticker prices. WordPress headless looks free until you add managed hosting, a CDN, object caching, security and backup services, and the engineering hours spent on plugin patching and database tuning. A managed platform folds infrastructure, scaling, and compliance into the subscription. The migration cost itself is front-loaded (modeling, transform scripts, editor rebuild) but it is a one-time investment that retires recurring operational tax, which is the calculation worth putting in front of whoever signs off.
WordPress headless (WPGraphQL) vs Sanity, Strapi, and Contentful: a migration lens
| Feature | Sanity | WordPress headless (WPGraphQL) | Strapi | Contentful |
|---|---|---|---|---|
| Content model | Code-first schemas via defineType, versioned in git, references are first-class, codegen'd to TypeScript with TypeGen. | Implicit across postmeta, ACF field groups, and theme logic; no single artifact describes the model. | Content-Type Builder writes to code; relations supported, model defined in config plus admin UI. | Content types defined in a hosted UI or via CMA; typed but model lives in Contentful's app, not your repo. |
| Query language | GROQ: one round trip with filters, -> reference joins, projections, match() and score() for search-style retrieval. | WPGraphQL: typed schema, but fragment-heavy for ACF layouts and resolvers may still hammer postmeta. | REST and GraphQL; populate/filter params, deep relations often need explicit population and extra calls. | GraphQL and REST (CDA); solid, but deep nested shaping and cross-references can mean multiple queries. |
| Editing interface | Sanity Studio is a React app you build: custom inputs in sanity.config.ts, Structure Builder, App SDK embeds. | wp-admin and Gutenberg; familiar to authors but tightly coupled to the legacy backend you are migrating off. | Configurable admin panel, self-hosted; customizable but not a full app you ship like Studio. | Polished hosted editor you configure; layout and field UI are not a code-owned application. |
| Rich text format | Portable Text: structured JSON with annotations, marks, and embedded references; portable and AI-readable. | HTML blob from the post body; frontend must parse and sanitize markup at render time. | Rich-text blocks (JSON) or Markdown depending on config; structured, less of a portability ecosystem. | Rich Text as a structured JSON document; portable within Contentful's renderer ecosystem. |
| In-context preview | Presentation Tool plus Visual Editing: click a rendered element, land on the exact field, headless preserved. | Native theme preview, but lost in headless mode unless you rebuild preview yourself. | Preview feature exists; live click-to-field visual editing is not a built-in equivalent. | Live Preview is available, with visual editing typically wired via a separate SDK and setup. |
| Operations and hosting | Managed Content Lake and APIs; no MySQL-under-load or plugin-CVE class of incidents to own. | You run PHP, MySQL, and the plugin supply chain; uptime, scaling, and patching are yours. | Self-hosted by default (or Strapi Cloud); you own the database and deployment for the OSS path. | Fully managed SaaS backend; hosting and scaling handled, less infrastructure to own. |
| Compliance posture | SOC 2 Type II, GDPR, regional hosting and data residency options, published sub-processor list. | Depends entirely on your hosting and plugin choices; compliance is a bespoke per-deployment exercise. | OSS path inherits your infra's posture; Strapi Cloud carries its own published certifications. | Enterprise SaaS with its own published compliance certifications and data-residency options. |
| Exit and lock-in | Schemas are code, content is Portable Text, full dataset exports to NDJSON via the CLI on demand. | Open source and self-hostable, but meaning is trapped in ACF config and theme logic that does not export cleanly. | Open source; database is yours, though export fidelity depends on your relation and component setup. | Export via CMA and CLI; portable JSON, though tied to Contentful's content type semantics. |