Sanity vs WordPress headless CMS: when headless actually beats traditional
Architecting for Predictability: A Data-Driven Migration from WordPress to Sanity
Current Situation Analysis
The decision to migrate a content management system is rarely driven by raw API benchmarks. In practice, teams treat CMS selection as a pure technology evaluation, overlooking the compounding costs of editor workflows, plugin dependency chains, and long-term schema drift. This misunderstanding stems from the assumption that wrapping a traditional CMS in a GraphQL layer automatically solves architectural limitations. It does not.
The industry pain point is clear: marketing and product teams demand rapid content iteration, while engineering teams are burdened by unpredictable delivery layers, security patches, and type-safety gaps. When a WordPress installation is retrofitted for headless delivery via WPGraphQL, the system inherits two distinct maintenance surfaces. The PHP runtime, MySQL query layer, and plugin ecosystem remain active, even if the frontend is decoupled. Cache misses trigger full PHP initialization cycles. Schema changes require PHP plugins or ACF field groups. TypeScript types are either manually maintained or generated from introspection queries that break the moment a plugin updates.
Data from a recent production migration illustrates the systemic impact. A mid-scale marketing property (400 pages, 3 content editors, ~180,000 monthly visits) operated on WordPress + WPGraphQL before transitioning to Sanity + Next.js App Router with Incremental Static Regeneration (ISR). The baseline metrics revealed structural inefficiencies: mobile LCP averaged 2.8 seconds, TTFB sat at 480 milliseconds on cache misses, and the plugin stack contained 31 active extensions. Twelve of those plugins existed solely to patch Gutenberg limitations or resolve conflicts with other extensions. Two carried unfixed CVEs. Hosting on WP Engine Growth consumed Β£65 monthly, with an additional Β£18 for the frontend Vercel Pro tier.
The migration exposed a fundamental truth: headless delivery is not a feature toggle. It is an architectural commitment to explicit content modeling, edge-native caching, and compile-time type safety. When those foundations are aligned, performance and maintenance costs shift dramatically. The same property, after migration, achieved 1.1s LCP, 60ms TTFB on edge-cached routes, and reduced recurring hosting to Β£18 monthly. The trade-off was a six-week engineering investment and a mandatory content model design phase. This is not a universal win condition. It is a calculated architectural pivot that only yields positive ROI when the property is expected to operate for 18+ months or when plugin maintenance debt has already become a critical path blocker.
WOW Moment: Key Findings
The migration data reveals a clear divergence in how traditional and modern headless architectures handle content delivery at scale. The following comparison isolates the operational metrics that directly impact engineering velocity, user experience, and total cost of ownership.
| Approach | Metric 1 | Metric 2 | Metric 3 |
|---|---|---|---|
| WordPress + WPGraphQL | 2.8s LCP (Mobile) | 480ms TTFB (Cache Miss) | 31 Active Plugins |
| Sanity + Next.js ISR | 1.1s LCP (Mobile) | 60ms TTFB (Edge Cached) | 0 CMS Plugins |
This finding matters because it decouples performance from infrastructure spending. The LCP reduction is not achieved by provisioning larger servers; it is the result of explicit content contracts, predictable query shapes, and edge-cached static generation. The TTFB drop demonstrates that bypassing PHP initialization and MySQL joins on every request eliminates the primary bottleneck in retrofitted headless architectures. Eliminating the plugin stack removes security audit overhead, dependency conflicts, and schema drift. What appears as a speed improvement is actually a structural simplification. Teams gain predictable GROQ queries, compile-time TypeScript validation, and a single deployment surface. The operational complexity shifts from runtime patching to upfront modeling, which compounds in efficiency over time.
Core Solution
Migrating to a modern headless architecture requires deliberate schema design, type-safe data fetching, and edge-optimized rendering. The following implementation demonstrates how to structure a Sanity content model, generate TypeScript types, and consume the data in a Next.js App Router environment with ISR.
Step 1: Define an Explicit Content Model
Sanity requires upfront schema decisions. Instead of relying on generic posts and pages, model documents around business entities. This enforces consistency across web, mobile, and email channels.
// src/sanity/schemas/campaign.ts
import { defineType, defineField } from 'sanity'
export const campaignSchema = defineType({
name: 'marketingCampaign',
title: 'Marketing Campaign',
type: 'document',
fields: [
defineField({
name: 'campaignId',
title: 'Internal ID',
type: 'string',
validation: (rule) => rule.required().regex(/^[A-Z]{2}-\d{4}$/),
}),
defineField({
name: 'heroSection',
title: 'Hero Configuration',
type: 'object',
fields: [
defineField({ name: 'headline', type: 'string' }),
defineField({ name: 'subtext', type: 'text' }),
defineField({ name: 'ctaLabel', type: 'string' }),
defineField({ name: 'ctaLink', type: 'url' }),
defineField({ name: 'backgroundImage', type: 'image' }),
],
}),
defineField({
name: 'relatedProducts',
title: 'Product References',
type: 'array',
of: [{ type: 'reference', to: { type: 'productListing' } }],
}),
defineField({
name: 'publishWindow',
title: 'Availability Window',
type: 'object',
fields: [
defineField({ name: 'startsAt', type: 'datetime' }),
defineField({ name: 'expiresAt', type: 'datetime' }),
],
}),
],
})
Why this structure? Nested objects group related UI concerns, reducing query fragmentation. Explicit validation rules catch malformed data at ingestion. Reference arrays enforce relational integrity without SQL joins. The publishWindow object enables time-based content gating without external cron jobs.
Step 2: Generate Compile-Time Types
Sanity v3 ships with typegen, which extracts TypeScript interfaces directly from schema definitions. This eliminates runtime type guessing and prevents schema drift.
npx sanity typegen generate
The command produces a sanity.types.ts file containing strictly typed interfaces for every document and field. Query results are now validated at compile time. If a field is renamed or removed, the build fails immediately rather than surfacing runtime errors in production.
Step 3: Implement Edge-Cached Data Fetching
Next.js App Router with ISR aligns naturally with Sanity's CDN-backed Content Delivery API. Pages are generated at build time and revalidated on a schedule or via webhook triggers.
// src/app/campaigns/[slug]/page.tsx
import { groq } from 'next-sanity'
import { client } from '@/lib/sanity-client'
import { CampaignHero } from '@/components/campaign-hero'
import { ProductGrid } from '@/components/product-grid'
const CAMPAIGN_QUERY = groq`
*[_type == "marketingCampaign" && slug.current == $slug][0] {
campaignId,
heroSection {
headline,
subtext,
ctaLabel,
ctaLink,
"imageMeta": backgroundImage.asset-> {
url,
dimensions { width, height }
}
},
relatedProducts[]-> {
name,
price,
"thumbnail": mainImage.asset->url
},
publishWindow {
startsAt,
expiresAt
}
}
`
export const revalidate = 3600 // ISR: revalidate every hour
export default async function CampaignPage({ params }: { params: { slug: string } }) {
const campaign = await client.fetch(CAMPAIGN_QUERY, { slug: params.slug })
if (!campaign) {
return <div className="p-8 text-center">Campaign not found</div>
}
return (
<main className="max-w-6xl mx-auto px-4 py-12">
<CampaignHero data={campaign.heroSection} />
<ProductGrid items={campaign.relatedProducts} />
</main>
)
}
Architecture Rationale:
- GROQ Projection: The query explicitly requests only required fields. This reduces payload size and prevents over-fetching.
- Asset Metadata Extraction: Sanity stores original image dimensions in the asset document. Projecting
dimensions.widthanddimensions.heightallows the frontend to pass explicit sizing tonext/image, eliminating Cumulative Layout Shift (CLS). - ISR Configuration:
revalidate = 3600balances freshness with edge performance. Content updates trigger background regeneration without blocking user requests. - Single Deployment Surface: The Sanity Studio, schema definitions, and Next.js application reside in one repository. Environment parity is guaranteed, and deployment pipelines remain streamlined.
Step 4: Handle Image Rendering with Explicit Dimensions
Layout shift remains the most common performance regression in headless migrations. Sanity's asset metadata solves this when consumed correctly.
// src/components/sanity-image.tsx
import Image from 'next/image'
import type { SanityImageSource } from '@/types/sanity.types'
interface Props {
asset: SanityImageSource
alt: string
className?: string
}
export function SanityImage({ asset, alt, className }: Props) {
const width = asset.dimensions?.width ?? 1200
const height = asset.dimensions?.height ?? 800
return (
<Image
src={asset.url}
alt={alt}
width={width}
height={height}
className={className}
priority={false}
/>
)
}
By extracting dimensions at query time, the component avoids layout thrashing. The fallback values ensure graceful degradation if metadata is missing, while the explicit width and height satisfy Lighthouse CLS requirements.
Pitfall Guide
1. Skipping the Content Model Workshop
Explanation: WordPress provides default post/page types out of the box. Sanity requires explicit schema design. Teams that jump straight into frontend development often create fragmented, inconsistent content structures that require costly refactoring. Fix: Allocate 8β12 hours upfront to map business entities, define reference relationships, and establish validation rules. Treat the schema as a contract, not an afterthought.
2. Relying on GraphQL Introspection for Type Safety
Explanation: WPGraphQL introspection generates types dynamically, but plugin updates frequently alter field shapes. This causes silent runtime failures or broken UI components. Fix: Use Sanity TypeGen or implement runtime validation with Zod/Valibot. Compile-time type generation ensures schema changes break the build, not production.
3. Mishandling Image Dimensions
Explanation: Traditional CMS media libraries often strip original dimensions during resizing. Frontend components guess aspect ratios, causing layout shifts and poor Core Web Vitals.
Fix: Query Sanity asset metadata explicitly. Pass width and height to the image component. Never rely on CSS-only aspect ratio hacks for critical above-the-fold content.
4. Underestimating Editor Adaptation
Explanation: Gutenberg users expect visual block assembly. Sanity Studio uses structured forms. Forcing an immediate switch causes resistance, publishing errors, and support tickets. Fix: Run both editors in parallel for 2β3 weeks. Provide field-level documentation, enforce validation rules, and conduct structured training sessions. Expect a two-week adaptation curve.
5. Ignoring Plugin Security Debt
Explanation: WordPress ecosystems accumulate plugins that patch other plugins. In the reference migration, 12 of 31 plugins existed solely to resolve conflicts. Two carried unfixed CVEs. Fix: Audit every plugin before migration. Replace niche integrations with focused API calls or serverless functions. Treat plugin count as a security liability, not a feature metric.
6. Misjudging the TCO Break-Even Point
Explanation: Migration requires engineering investment. The reference project took six weeks. Hosting savings of Β£47/month only justify the effort if the property operates for 18+ months or if maintenance overhead is critical. Fix: Calculate migration ROI explicitly. Do not migrate short-lived brochure sites or properties with minimal content velocity. Reserve architectural pivots for long-term digital assets.
7. Overcomplicating GROQ Queries
Explanation: Developers accustomed to SQL or GraphQL often write deeply nested GROQ queries that fetch unnecessary data, increasing payload size and edge latency. Fix: Use projection syntax to request only required fields. Leverage Sanity's built-in filtering and sorting. Keep queries flat and predictable. Profile payload sizes in production.
Production Bundle
Action Checklist
- Content Model Audit: Map all business entities, define reference relationships, and establish validation rules before writing frontend code.
- Type Generation Pipeline: Run
sanity typegen generatein CI/CD to enforce compile-time type safety and prevent schema drift. - Image Dimension Projection: Query Sanity asset metadata explicitly and pass
width/heightto the Next.js Image component to eliminate CLS. - ISR Configuration: Set
revalidateintervals based on content velocity. Use webhook triggers for immediate regeneration on critical updates. - Plugin Debt Assessment: Audit existing WordPress plugins. Replace niche integrations with focused API calls or serverless functions.
- Editor Parallel Run: Operate both CMS interfaces for 2β3 weeks. Document field mappings and conduct structured training sessions.
- TCO Calculation: Model migration costs against expected hosting savings and maintenance reduction. Proceed only if ROI threshold is met.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| High content velocity, multi-channel delivery | Sanity + Next.js ISR | Explicit schema, edge caching, TS-native pipeline | Higher upfront, lower long-term |
| Heavy WooCommerce, existing WP team | WordPress Headless + WPGraphQL | Leverages existing ecosystem, faster initial delivery | Lower upfront, higher maintenance |
| Short-lived campaign site (<12 months) | Static site generator or WP shared hosting | Migration ROI negative, minimal content complexity | Lowest total cost |
| Strict compliance/security requirements | Sanity + Vercel Edge | Reduced attack surface, no plugin CVEs, auditable schema | Moderate hosting, lower security overhead |
| Large editorial team, Gutenberg dependency | WordPress with custom block library | Zero training friction, familiar workflow | Higher plugin maintenance, predictable UX |
Configuration Template
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemas'
export default defineConfig({
name: 'default',
title: 'Production Studio',
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
form: {
filter: ({ document }) => {
// Enforce publish window validation
if (document._type === 'marketingCampaign') {
const start = new Date(document.publishWindow?.startsAt)
const end = new Date(document.publishWindow?.expiresAt)
if (end <= start) return { message: 'End date must be after start date' }
}
return {}
},
},
})
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
pathname: '/images/**',
},
],
formats: ['image/avif', 'image/webp'],
},
experimental: {
optimizePackageImports: ['@sanity/image-url', 'next-sanity'],
},
}
module.exports = nextConfig
Quick Start Guide
- Initialize the Studio: Run
npm create sanity@latest -- --template cleanin your project root. Configure your dataset and project ID via the Sanity CLI. - Define Your Schema: Create document types in
src/sanity/schemas/. UsedefineTypeanddefineFieldto model business entities. Runnpx sanity typegen generateto produce TypeScript interfaces. - Connect Next.js: Install
next-sanityand@sanity/client. Configure the client with your project credentials. Set up GROQ queries with explicit field projection. - Enable ISR: Add
export const revalidate = 3600to your page components. Configure Vercel or your edge provider to cache responses. Test regeneration via Sanity webhook triggers. - Validate & Deploy: Run
npm run buildto verify type safety and query correctness. Deploy to your edge platform. Monitor LCP, TTFB, and CLS metrics in production. Adjust revalidation intervals based on content velocity.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
