Skip to main content
ASoc
Comparison

Sanity vs Headless WordPress: The Decision Is About Who Edits

Sanity gives you a schema in TypeScript and nothing to patch. Headless WordPress gives you an editor your team knows, and a PHP stack you still operate.

The ASoc Team8 min read

Pick Sanity when the content model matters more than the editing surface, and headless WordPress when the editors are already trained on WordPress and you cannot retrain them. Sanity gives you a schema you define in TypeScript and a hosted API. Headless WordPress gives you an editor your team already knows, and a PHP backend you now have to keep alive anyway.

Both decisions leave the front end untouched. That is the part most comparisons bury, and it is the part that decides what you can build this week: whichever one you choose, the marketing site is still a Next.js app rendering React components, and a template that ships those components is worth the same under either CMS.

The comparison that matters

SanityHeadless WordPress
Where content livesHosted Content LakeYour MySQL database
What you operateNothing — the backend is managedPHP, MySQL, WordPress core, plugin updates
Content modelSchema defined in TypeScript, versioned in GitPost types + custom fields, configured in the admin (or in PHP)
Query interfaceGROQ, or GraphQLREST (/wp-json/wp/v2) or WPGraphQL, a plugin
EditorSanity Studio — React, open source, you deploy itGutenberg — already familiar to millions
Typed contentSchema-first, types generated from itUntyped JSON off the API; you write the types
Real-time co-editingBuilt inNo
ImagesHosted CDN with URL-based transformsMedia library; transforms are your problem
Structural change after launchEdit the schema, redeploy StudioEdit field groups in the admin
Cost shapeSeats + API usageHosting + plugin licences
Migration outExport documents as JSONExport XML/SQL, then rewrite the layer that read it

Two rows carry the decision. What you operate is the one that shows up in a year of maintenance tickets: headless WordPress does not stop being WordPress, so you still patch it, still update plugins, and still carry a public login page even though nobody visits your PHP site any more. Content model is the one that shows up in week two, when someone asks for a field that does not exist yet.

The trap in "headless WordPress"

Going headless removes the theme, not the server. You keep the admin, the database, the update treadmill and the attack surface, and you add a second deployment — the front end — that can now break independently. The pitch is that you get a modern front end without retraining editors. That is true, and it is a real benefit when your editors are the reason the project exists. It is not a reduction in operational work; it is an increase, paid for in editor familiarity.

Sanity makes the opposite trade. There is nothing to patch, but the schema is code, so every structural change is a developer task and a deploy. If a marketer needs a new field on a page type and no developer is free until Thursday, the field arrives on Thursday.

Neither answer is better. They are the same question asked from two directions: is your bottleneck editor training, or developer availability? If the answer is developer availability and you would rather not hand the backend to a vendor at all, the self-hosted version of this trade-off is Sanity vs Payload. And if you are still deciding whether to keep WordPress rendering the site in the first place, that is a different question again — WordPress vs Next.js for a marketing site takes it head on.

What the front end looks like either way

Here is the part the comparison posts skip. Under Sanity you fetch with GROQ; under headless WordPress you fetch from the REST API. In an App Router Server Component, the shape is the same — an async component, a fetch, and the same JSX underneath:

// Sanity
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await sanity.fetch(
    `*[_type == "post" && slug.current == $slug][0]{ title, body }`,
    { slug },
  );
  return <Article title={post.title} body={post.body} />;
}

// Headless WordPress
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const [post] = await fetch(
    `${process.env.WP_URL}/wp-json/wp/v2/posts?slug=${slug}`,
  ).then((r) => r.json());
  return <Article title={post.title.rendered} body={post.content.rendered} />;
}

<Article> does not care. That is why the CMS decision and the template decision are genuinely independent, and why you can defer one without blocking the other.

The differences that are real sit just below that line. Sanity returns structured blocks, so body is data you render with your own components — which means a heading in the CMS becomes your heading component, with your spacing and your id attributes. WordPress returns content.rendered, a string of HTML produced by Gutenberg, which you will end up injecting with dangerouslySetInnerHTML and then styling from the outside. That single difference is what "structured content" means in practice, and it is worth more than any feature list.

What this codebase does instead, and why it is not a recommendation

This storefront runs neither. The blog you are reading is 262 MDX files compiled at build time — 458,000 words of prose — against a typed metadata registry in src/data/blog.ts:

export interface BlogPostMeta {
  slug: string;
  title: string;
  description: string;
  date: string;
  cluster: BlogCluster;
  targetKeyword: string;
  readingMinutes: number;
  /** Catalog slugs this post funnels to. */
  relatedTemplates: string[];
  /** Root-level landing routes this post links to. */
  relatedSpokes: string[];
  tags: string[];
}

src/lib/blog.ts pairs each entry with its prose through an explicit slug → import() map, and src/data/__tests__/blog.test.ts holds sixteen invariants that fail the build if the registry, the MDX file and the loader ever disagree. A full production build emits 395 prerendered HTML pages in 37 seconds, with zero CMS API calls at request time and zero monthly CMS bill.

That works here for one reason: everyone who writes for this site can open a pull request. The moment that stops being true, the argument collapses, and the choice above becomes live. Files in Git are not a third option in the comparison — they are what you use while the answer to "who edits?" is still "us".

One thing the file-based route does buy that is worth naming, because it is the failure mode both CMS options share: broken internal links. A relatedTemplates entry pointing at a retired product is a failing test here. In either CMS it is a 404 a reader finds before you do.

The defect that cost us an afternoon

Worth knowing before you wire any content pipeline into a modern Next.js build, because it fails silently rather than loudly. MDX plugins in next.config.ts are named as strings, not imported:

const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm"],
    rehypePlugins: ["rehype-slug"],
  },
});

Turbopack runs the MDX pipeline in Rust and cannot receive a JavaScript function reference across that boundary. Passing the imported plugin — the way every older tutorial shows it — builds successfully and silently drops the plugin. The symptom was that every comparison table in every post rendered as paragraphs of literal pipe characters, with no warning anywhere in the build output.

The general lesson applies to any headless setup: the integration layer between a content source and a Rust-based bundler is where config fails quietly. Verify the output, not the exit code.

Mistakes and how they show up

SymptomCauseFix
Headless WordPress site is fast, admin is still being attackedGoing headless removed the theme, not the login pageLock /wp-admin and /wp-login.php at the network edge; the front end never needs them
Editors "can't find" a field after launchSanity schema changes need a developer and a Studio deployModel the fields editors will ask for, not only the ones the design needs today
WordPress content renders unstyled in Reactcontent.rendered is a plain HTML string, outside your component stylesStyle it from a wrapper, or parse the blocks and render your own components
Preview works locally, shows stale content in productionStatic rendering cached the fetch at build timeUse a draft/preview route that opts out of caching, not a global opt-out
GROQ query returns null for a field that existsThe field is on a referenced document, not the one queriedDereference explicitly (->) — GROQ does not follow references for you
Table syntax renders as literal pipesAn MDX plugin was passed as an imported function under TurbopackName plugins as strings, then check the rendered HTML

Frequently asked questions

Is headless WordPress cheaper than Sanity? Usually on the invoice, rarely in total. You trade a per-seat bill for hosting plus the hours someone spends patching WordPress, updating plugins and fixing the two deployments that can now break separately. If you were already paying for WordPress hosting and the editors are already trained, the sum genuinely can come out lower. If you are starting fresh, you are standing up a PHP stack to avoid a subscription.

Can I decide the CMS later and build the marketing site now? Yes, and it is usually the right order. Both options hand you content in an async Server Component; the components rendering it are identical. Build the site against typed local data, then swap the data source. The work you would throw away is the fetch layer, not the front end.

Does Sanity lock me in? Partly, and honestly less than the alternative. Documents export as JSON and the Studio is open source, so the content leaves cleanly. What does not leave is GROQ — every query you wrote gets rewritten. Compare that to a WordPress export, where the XML comes out fine and the plugin-specific custom fields inside it are the part that hurts.

Which one is better for SEO? Neither, at the CMS layer. Rankings come from what the front end emits — the rendered HTML, the metadata, the internal links and how fast it paints. This site's main pages score 100 on Lighthouse performance, accessibility and SEO on desktop with no CMS at all, and the same front end would score the same reading from either one. Choose on editorial workflow and pick a front end that renders static HTML.

Templates in this post

ASoc Nexus is a SaaS marketing site that already includes a blog section, and ASoc Nimbus and ASoc Nova are built from the same component conventions — so the article and listing components are already in place when you wire up whichever CMS you land on.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison7 min read

Sanity vs Payload CMS: Hosted Content Lake or a CMS in Your Repo

Payload installs into your Next.js app, so content reads skip HTTP entirely. Sanity hands the 3am pager to someone else. Measured against a build with no CMS at all.

Read more
Comparison7 min read

Sass vs. Tailwind: 133 Lines and One Arbitrary Selector

Sass's four features, checked one at a time against this codebase's real @theme block, group-hover usage, and the single [&_selector] Tailwind still reaches for.

Read more