Sanity CMS vs Contentful for Next.js projects: an honest comparison
Architecting Headless Content for Next.js: Schema Strategy, Query Paradigms, and Cache Control
Current Situation Analysis
Selecting a headless CMS for a Next.js application is rarely a simple feature comparison. The industry pain point stems from treating content infrastructure as a static vendor choice rather than a workflow architecture decision. Teams frequently evaluate platforms based on dashboard screenshots or marketing claims, only to encounter severe friction during schema migrations, cache invalidation, or cross-referenced data fetching.
The core misunderstanding lies in conflating "developer experience" with "editor experience." Contentful positions itself as a SaaS-first platform where the schema lives in a managed dashboard, APIs are hosted, and the delivery layer is fully abstracted. Sanity takes a fundamentally different approach: it provides a content lake backend paired with an open-source Studio that developers own, configure, and deploy alongside their application. Schemas are defined as TypeScript files in the repository, and queries are written in GROQ, a purpose-built language designed for content graph traversal.
This architectural divergence dictates downstream decisions. Schema-in-repo enables version control, environment parity, and CI/CD-driven type generation. Schema-in-dashboard accelerates non-technical onboarding but introduces deployment drift and limits programmatic control. Pricing shifts in 2026 further clarify the trade-off: Sanity's free tier supports 3 users (2 non-admin), 500,000 API CDN requests monthly, and 10 GB bandwidth, with a Growth tier starting around $15/month per seat. Contentful's Community plan offers 5 users and 1 million API calls but caps content types at 48, while the Basic tier jumps to approximately $300/month flat. For small engineering teams or freelance engagements, the cost and workflow implications are substantial.
The problem is overlooked because most comparison guides focus on UI polish or feature parity. In production, the real constraints are query expressiveness, cache integration granularity, and how schema changes propagate through the build pipeline. Ignoring these factors leads to bloated bundles, stale content, and editorial bottlenecks.
WOW Moment: Key Findings
The architectural divergence between these platforms creates measurable differences in developer velocity, cache control, and operational cost. The following comparison isolates the factors that actually impact production deployments.
| Approach | Query Paradigm | Schema Management | Cache Integration | Entry Cost (2026) |
|---|---|---|---|---|
| Contentful | GraphQL | Dashboard-driven | SDK-mediated | ~$300/mo flat |
| Sanity | GROQ | Repository-driven | Native fetch tags |
Free tier (500k req/mo) |
Why this matters: The table reveals that platform selection is not about which CMS has more features, but which aligns with your deployment pipeline and team structure. GraphQL's ecosystem familiarity reduces onboarding time but requires SDK abstraction that obscures cache control. GROQ's proprietary nature demands initial investment but enables single-pass dereferencing and projection without fragment nesting. Repository-driven schemas integrate with Git workflows and enable deterministic type generation, while dashboard-driven models prioritize editorial autonomy at the cost of programmatic control. Cache integration is the silent multiplier: native fetch tag mapping enables precise ISR invalidation, whereas SDK-mediated requests often force full-page revalidation or manual webhook parsing.
Core Solution
Building a production-ready Next.js application with a headless CMS requires aligning query strategy, cache architecture, and schema management. The following implementation demonstrates a documentation hub using Sanity's repository-driven model, GROQ, and Next.js App Router caching primitives.
Step 1: Define the Content Schema in TypeScript
Schema-in-repo means content models are version-controlled and type-safe. Define document types as TypeScript interfaces and register them with the Sanity Studio configuration.
// src/sanity/schema/docPage.ts
import { defineType, defineField } from 'sanity'
export const docPage = defineType({
name: 'docPage',
title: 'Documentation Page',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'section',
title: 'Parent Section',
type: 'reference',
to: [{ type: 'docSection' }],
}),
defineField({
name: 'body',
title: 'Content',
type: 'array',
of: [{ type: 'block' }],
}),
defineField({
name: 'lastUpdated',
title: 'Last Updated',
type: 'datetime',
}),
],
})
Why this choice: Defining schemas as code enables git diff for content model changes, automated testing of validation rules, and deterministic TypeScript generation. Environment-specific configurations (e.g., staging vs production datasets) are managed through environment variables rather than dashboard toggles.
Step 2: Construct GROQ Queries with Projection and Dereferencing
GROQ eliminates the need for nested fragments by allowing inline dereferencing and field projection. This reduces round-trips and simplifies response shapes.
// src/sanity/queries/getDocPage.ts
import { defineQuery } from 'next-sanity'
export const getDocPageQuery = defineQuery(`
*[_type == "docPage" && slug.current == $slug] {
_id,
title,
"slug": slug.current,
lastUpdated,
"section": section-> {
_id,
title,
"slug": slug.current
},
body,
"relatedPages": *[_type == "docPage" && section._ref == ^.section._ref && _id != ^._id] {
_id,
title,
"slug": slug.current
}[0...3]
}[0]
`)
Why this choice: The query resolves the parent section, fetches the page body, and retrieves related pages in a single pass. The ^ operator references the current document context, enabling relational filtering without client-side joins. This reduces payload size and eliminates the fragment boilerplate required in GraphQL schemas.
Step 3: Integrate with Next.js App Router and ISR Cache
The next-sanity package wraps the Sanity client and integrates directly with Next.js's fetch cache. This enables granular revalidation using tag-based invalidation.
// src/app/docs/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { sanityClient } from '@/sanity/client'
import { getDocPageQuery } from '@/sanity/queries/getDocPage'
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
interface PageProps {
params: Promise<{ slug: string }>
}
export default async function DocPage({ params }: PageProps) {
const { slug } = await params
const page = await sanityClient.fetch(getDocPageQuery, { slug }, {
next: {
tags: [`doc:${slug}`, `doc:section:${slug}`],
revalidate: 3600,
},
})
if (!page) return notFound()
return (
<article className="prose max-w-none">
<h1>{page.title}</h1>
<PortableTextRenderer value={page.body} />
{page.relatedPages.length > 0 && (
<nav className="mt-8 border-t pt-4">
<h2>Related Documentation</h2>
<ul>
{page.relatedPages.map((rel) => (
<li key={rel._id}>
<a href={`/docs/${rel.slug}`}>{rel.title}</a>
</li>
))}
</ul>
</nav>
)}
</article>
)
}
Why this choice: Passing tags to the next configuration maps directly to Next.js's revalidation system. When a document is published or updated, a Sanity webhook triggers revalidateTag('doc:${slug}'), invalidating only the affected route. This avoids full-site rebuilds and keeps edge caches warm. The revalidate: 3600 fallback ensures stale content is refreshed hourly if webhooks fail.
Step 4: Generate TypeScript Types from Queries
GROQ's proprietary nature means generic tooling doesn't apply. Sanity TypeGen bridges this gap by parsing queries and generating strict TypeScript interfaces.
# Run during build or pre-commit
npx sanity typegen generate
The generated types are imported alongside queries, ensuring compile-time safety for response shapes. This eliminates any casting and catches schema drift before deployment.
Architecture Rationale: The combination of repository schemas, GROQ projection, and native fetch tags creates a deterministic content pipeline. Changes to content models trigger type regeneration, queries are validated at build time, and cache invalidation is scoped to specific routes. This reduces runtime overhead and prevents stale data from persisting across deployments.
Pitfall Guide
1. Schema Drift Across Environments
Explanation: Dashboard-driven schemas can diverge between staging and production if editors modify fields directly in the CMS. This causes build failures or runtime type mismatches. Fix: Enforce schema-in-repo for all structural changes. Use Sanity's dataset cloning or Contentful's content export/import APIs to sync environments. Validate schema changes through CI before merging.
2. Ignoring GROQ Projection Limits
Explanation: Developers often fetch entire documents and filter client-side, negating GROQ's efficiency. This increases payload size and slows edge rendering.
Fix: Always project only required fields. Use inline dereferencing (->) and array slicing ([0...N]) to minimize response weight. Profile query execution time using Sanity's query inspector.
3. Misconfigured Revalidation Tags
Explanation: Omitting tags or using generic identifiers (page:all) forces full cache invalidation, defeating ISR benefits and increasing origin load.
Fix: Scope tags to document types and slugs (doc:${slug}, section:${id}). Map webhook payloads to specific tag arrays. Test invalidation paths in staging before production deployment.
4. Over-fetching via SDK Abstraction
Explanation: Using CMS SDKs that wrap fetch obscures cache control and increases bundle size. Next.js cannot optimize requests it cannot inspect.
Fix: Prefer native fetch wrappers like next-sanity or @contentful/rich-text only when necessary. Bypass SDK caching layers when precise ISR control is required.
5. Neglecting Portable Text Component Mapping
Explanation: Treating rich text as flat HTML loses editorial flexibility. Inline callouts, embedded components, and custom image crops require explicit React mappings.
Fix: Implement a PortableTextRenderer that maps block types to components. Define fallback renderers for unknown types. Validate editor inputs against component expectations during QA.
6. Underestimating TypeGen Pipeline Requirements
Explanation: Skipping type generation leads to runtime type errors and any casting. Queries change frequently, and manual type updates are unsustainable.
Fix: Integrate sanity typegen generate into the build pipeline. Add a pre-commit hook to catch schema-query mismatches. Document the generation step in onboarding guides.
7. Treating the Studio as a Black Box
Explanation: Assuming the CMS UI is static ignores customization opportunities. Structure Builder, custom inputs, and document badges can drastically improve editorial workflows.
Fix: Audit editorial pain points and implement targeted Studio extensions. Use structureBuilder to organize documents logically. Add validation badges to highlight draft vs published states.
Production Bundle
Action Checklist
- Define content schemas as TypeScript files and commit to version control
- Generate TypeScript types using
sanity typegen generatebefore each build - Scope revalidation tags to document slugs and section identifiers
- Map webhook payloads to specific
revalidateTagcalls in the API route - Implement Portable Text component mappings for all custom block types
- Profile GROQ queries using the Sanity query inspector to verify projection efficiency
- Configure environment-specific datasets and validate schema parity across stages
- Add fallback revalidation intervals to prevent stale content during webhook outages
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Small dev team (2-4 editors) | Sanity free tier + schema-in-repo | Covers 500k requests/mo, enables Git workflow, avoids $300/mo jump | $0-$15/mo |
| Enterprise content ops team | Contentful Basic/Enterprise | Dashboard schema, field-level localization, mature permissions | ~$300+/mo |
| High-traffic ISR documentation | Sanity + native fetch tags |
Granular cache invalidation, GROQ projection reduces payload | Scales with bandwidth |
| Multi-region localization | Contentful | Built-in field-level locale management, translation workflows | Higher tier required |
| Custom editorial workflows | Sanity Studio customization | Structure Builder, custom inputs, document badges | Development time |
Configuration Template
// src/sanity/client.ts
import { createClient } from 'next-sanity'
export const sanityClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: '2024-10-01',
useCdn: process.env.NODE_ENV === 'production',
token: process.env.SANITY_API_TOKEN,
})
// src/sanity/webhook/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const payload = await req.json()
const { _type, slug, _id } = payload
if (slug?.current) {
revalidateTag(`${_type}:${slug.current}`)
}
revalidateTag(`${_type}:all`)
return NextResponse.json({ revalidated: true, now: Date.now() })
}
Quick Start Guide
- Initialize the project: Run
npx create-next-app@latest my-docs-siteand select TypeScript, App Router, and Tailwind CSS. - Add Sanity: Execute
npm install next-sanity @sanity/clientand runnpx sanity initto configure your project ID and dataset. - Generate types: Run
npx sanity typegen generateto create TypeScript interfaces from your GROQ queries. Add this to yourpackage.jsonbuild script. - Configure caching: Implement
next-sanityfetch withtagsandrevalidateoptions. Set up a webhook route to callrevalidateTagon document updates. - Deploy: Push to your hosting provider, configure environment variables (
NEXT_PUBLIC_SANITY_PROJECT_ID,SANITY_API_TOKEN), and verify ISR invalidation by publishing a test document.
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
