Contentful vs Sanity vs Hygraph: Enterprise Headless CMS (2026)
TL;DR
Contentful is the enterprise content platform — structured content models, REST and GraphQL APIs, rich text, localization, workflows, the most established headless CMS. Sanity is the composable content cloud — real-time collaboration, GROQ query language, Sanity Studio (customizable React editor), structured content, portable text. Hygraph (formerly GraphCMS) is the GraphQL-native headless CMS — federation-ready, content modeling UI, components, localization, built entirely around GraphQL. In 2026: Contentful for enterprise content operations, Sanity for developer-customizable content editing, Hygraph for GraphQL-first content management.
Key Takeaways
- Contentful: contentful ~400K weekly downloads — enterprise, REST/GraphQL, established
- Sanity: @sanity/client ~300K weekly downloads — real-time, GROQ, customizable studio
- Hygraph: @hygraph/management-sdk ~30K weekly downloads — GraphQL-native, federation
- Contentful has the largest enterprise customer base
- Sanity has the most customizable editing experience (React-based Studio)
- Hygraph is built entirely around GraphQL from the ground up
Contentful
Contentful — enterprise content platform:
Content delivery
import { createClient } from "contentful"
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
// Fetch entries:
const entries = await client.getEntries({
content_type: "package",
order: ["-fields.downloads"],
limit: 20,
include: 2, // Resolve 2 levels of linked entries
})
entries.items.forEach((entry) => {
console.log(`${entry.fields.name}: ${entry.fields.downloads} downloads`)
})
// Fetch single entry:
const pkg = await client.getEntry("entry-id-123", {
include: 2,
})
// Fetch with locale:
const localizedEntries = await client.getEntries({
content_type: "package",
locale: "de",
})
// Search:
const results = await client.getEntries({
content_type: "package",
"fields.name[match]": "react",
"fields.downloads[gte]": 1000000,
"fields.tags[in]": "frontend,ui",
})
Rich text rendering
import { documentToReactComponents } from "@contentful/rich-text-react-renderer"
import { BLOCKS, INLINES } from "@contentful/rich-text-types"
const options = {
renderNode: {
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
if (entry.sys.contentType.sys.id === "codeBlock") {
return (
<pre>
<code className={`language-${entry.fields.language}`}>
{entry.fields.code}
</code>
</pre>
)
}
},
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const { url, description } = node.data.target.fields.file
return <img src={`https:${url}`} alt={description} />
},
[INLINES.HYPERLINK]: (node, children) => (
<a href={node.data.uri} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
},
}
function ArticlePage({ article }: { article: any }) {
return (
<article>
<h1>{article.fields.title}</h1>
<div>{documentToReactComponents(article.fields.body, options)}</div>
</article>
)
}
Content management API
import { createClient } from "contentful-management"
const client = createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
})
const space = await client.getSpace(process.env.CONTENTFUL_SPACE_ID!)
const environment = await space.getEnvironment("master")
// Create entry:
const entry = await environment.createEntry("package", {
fields: {
name: { "en-US": "react" },
description: { "en-US": "UI library for building interfaces" },
downloads: { "en-US": 25000000 },
version: { "en-US": "19.0.0" },
tags: { "en-US": ["frontend", "ui"] },
},
})
// Publish entry:
await entry.publish()
// Update entry:
entry.fields.downloads["en-US"] = 26000000
await entry.update()
await entry.publish()
// Create content type (content model):
const contentType = await environment.createContentTypeWithId("package", {
name: "Package",
fields: [
{ id: "name", name: "Name", type: "Symbol", required: true },
{ id: "description", name: "Description", type: "Text" },
{ id: "downloads", name: "Downloads", type: "Integer" },
{ id: "version", name: "Version", type: "Symbol" },
{ id: "tags", name: "Tags", type: "Array", items: { type: "Symbol" } },
],
})
await contentType.publish()
Sanity
Sanity — composable content cloud:
GROQ queries
import { createClient } from "@sanity/client"
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: "production",
apiVersion: "2026-03-09",
useCdn: true, // Use CDN for reads
})
// GROQ query — fetch packages:
const packages = await client.fetch(`
*[_type == "package"] | order(downloads desc) [0...20] {
_id,
name,
description,
downloads,
version,
"tags": tags[]->name,
"author": author->{name, email}
}
`)
// Filtered query:
const reactPackages = await client.fetch(`
*[_type == "package" && "frontend" in tags[]->name && downloads > 1000000] {
name,
downloads,
version
}
`)
// Search:
const results = await client.fetch(`
*[_type == "package" && name match "react*"] | score(
boost(name match "react", 3),
boost(description match "react", 1)
) | order(_score desc) [0...10] {
_score,
name,
description,
downloads
}
`)
// With parameters (prevents injection):
const filtered = await client.fetch(
`*[_type == "package" && downloads >= $minDownloads] | order(downloads desc)`,
{ minDownloads: 1000000 }
)
Sanity Studio (schema definition)
// schemas/package.ts
import { defineType, defineField } from "sanity"
export const packageSchema = defineType({
name: "package",
title: "Package",
type: "document",
fields: [
defineField({
name: "name",
title: "Name",
type: "string",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: { source: "name" },
}),
defineField({
name: "description",
title: "Description",
type: "text",
rows: 3,
}),
defineField({
name: "downloads",
title: "Weekly Downloads",
type: "number",
}),
defineField({
name: "version",
title: "Latest Version",
type: "string",
}),
defineField({
name: "tags",
title: "Tags",
type: "array",
of: [{ type: "reference", to: [{ type: "tag" }] }],
}),
defineField({
name: "body",
title: "Content",
type: "array",
of: [
{ type: "block" }, // Portable Text
{ type: "image" },
{ type: "code" },
],
}),
],
preview: {
select: {
title: "name",
subtitle: "version",
downloads: "downloads",
},
prepare({ title, subtitle, downloads }) {
return {
title,
subtitle: `v${subtitle} — ${downloads?.toLocaleString()} downloads`,
}
},
},
})
Real-time subscriptions and mutations
import { createClient } from "@sanity/client"
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: "production",
apiVersion: "2026-03-09",
useCdn: false, // Must be false for mutations
token: process.env.SANITY_WRITE_TOKEN!,
})
// Create document:
await client.create({
_type: "package",
name: "react",
description: "UI library for building interfaces",
downloads: 25000000,
version: "19.0.0",
})
// Patch document:
await client.patch("document-id")
.set({ downloads: 26000000 })
.inc({ viewCount: 1 })
.commit()
// Transaction:
await client.transaction()
.create({ _type: "package", name: "new-pkg", downloads: 0, version: "1.0.0" })
.patch("other-doc-id", (p) => p.set({ status: "updated" }))
.commit()
// Real-time listener:
const subscription = client.listen(
`*[_type == "package" && downloads > 1000000]`
).subscribe((update) => {
console.log(`${update.transition}: ${update.result?.name}`)
// "appear", "update", "disappear"
})
Portable Text rendering
import { PortableText } from "@portabletext/react"
const components = {
types: {
image: ({ value }) => (
<img src={urlFor(value).width(800).url()} alt={value.alt} />
),
code: ({ value }) => (
<pre>
<code className={`language-${value.language}`}>{value.code}</code>
</pre>
),
},
marks: {
link: ({ children, value }) => (
<a href={value.href} target="_blank" rel="noopener">{children}</a>
),
},
}
function ArticlePage({ article }: { article: any }) {
return (
<article>
<h1>{article.title}</h1>
<PortableText value={article.body} components={components} />
</article>
)
}
Hygraph
Hygraph — GraphQL-native headless CMS:
GraphQL queries
import { GraphQLClient, gql } from "graphql-request"
const client = new GraphQLClient(process.env.HYGRAPH_ENDPOINT!, {
headers: {
Authorization: `Bearer ${process.env.HYGRAPH_TOKEN}`,
},
})
// Query packages:
const { packages } = await client.request(gql`
query GetPackages {
packages(orderBy: downloads_DESC, first: 20) {
id
name
description
downloads
version
tags {
name
}
}
}
`)
// Filtered query:
const { packages: filtered } = await client.request(gql`
query FilteredPackages($minDownloads: Int!) {
packages(
where: { downloads_gte: $minDownloads, tags_some: { name: "frontend" } }
orderBy: downloads_DESC
) {
name
downloads
version
}
}
`, { minDownloads: 1000000 })
// Full-text search:
const { packages: results } = await client.request(gql`
query SearchPackages($query: String!) {
packages(where: { _search: $query }) {
name
description
downloads
}
}
`, { query: "react" })
// With localization:
const { packages: localized } = await client.request(gql`
query LocalizedPackages {
packages(locales: [de, en]) {
name
description
localizations {
locale
description
}
}
}
`)
Content mutations
import { GraphQLClient, gql } from "graphql-request"
const client = new GraphQLClient(process.env.HYGRAPH_ENDPOINT!, {
headers: {
Authorization: `Bearer ${process.env.HYGRAPH_MANAGEMENT_TOKEN}`,
},
})
// Create entry:
const { createPackage } = await client.request(gql`
mutation CreatePackage($data: PackageCreateInput!) {
createPackage(data: $data) {
id
name
}
}
`, {
data: {
name: "react",
description: "UI library for building interfaces",
downloads: 25000000,
version: "19.0.0",
tags: {
connect: [{ where: { name: "frontend" } }],
},
},
})
// Publish:
await client.request(gql`
mutation PublishPackage($id: ID!) {
publishPackage(where: { id: $id }) {
id
stage
}
}
`, { id: createPackage.id })
// Update:
await client.request(gql`
mutation UpdatePackage($id: ID!, $data: PackageUpdateInput!) {
updatePackage(where: { id: $id }, data: $data) {
id
downloads
}
}
`, {
id: "pkg-id",
data: { downloads: 26000000 },
})
// Batch mutations:
await client.request(gql`
mutation BatchUpdate {
updateManyPackages(
where: { tags_some: { name: "deprecated" } }
data: { active: false }
) {
count
}
}
`)
Rich content and components
import { RichText } from "@graphcms/rich-text-react-renderer"
// Hygraph Rich Text rendering:
function ArticlePage({ article }: { article: any }) {
return (
<article>
<h1>{article.title}</h1>
<RichText
content={article.content.json}
references={article.content.references}
renderers={{
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8">{children}</h2>,
code_block: ({ children }) => (
<pre className="bg-gray-900 rounded-lg p-4">
<code>{children}</code>
</pre>
),
Asset: {
image: ({ url, altText, width, height }) => (
<img src={url} alt={altText} width={width} height={height} />
),
},
embed: {
Package: ({ name, downloads, version }) => (
<div className="card">
<h3>{name} v{version}</h3>
<p>{downloads.toLocaleString()} weekly downloads</p>
</div>
),
},
}}
/>
</article>
)
}
// Webhooks:
// Hygraph sends webhooks on content changes:
app.post("/api/webhooks/hygraph", async (req, res) => {
const { operation, data } = req.body
switch (operation) {
case "publish":
await revalidatePath(`/packages/${data.slug}`)
break
case "unpublish":
await purgeCache(data.slug)
break
}
res.sendStatus(200)
})
Feature Comparison
| Feature | Contentful | Sanity | Hygraph |
|---|---|---|---|
| API | REST + GraphQL | GROQ + GraphQL | GraphQL (native) |
| Query language | REST filters / GraphQL | GROQ (powerful) | GraphQL |
| Content modeling | Visual + API | Code (schemas) | Visual UI |
| Rich text | Contentful Rich Text | Portable Text | Rich Text (AST) |
| Real-time | Webhooks | ✅ (live queries) | Webhooks |
| Collaboration | ✅ | ✅ (real-time) | ✅ |
| Localization | ✅ (field-level) | ✅ (document-level) | ✅ (field-level) |
| Workflows | ✅ (enterprise) | ✅ (basic) | ✅ |
| Asset management | ✅ (CDN) | ✅ (CDN) | ✅ (CDN) |
| Webhooks | ✅ | ✅ | ✅ |
| Custom editor | ❌ (app framework) | ✅ (Sanity Studio) | ❌ (extensions) |
| Federation | ❌ | ❌ | ✅ (content federation) |
| TypeScript SDK | ✅ | ✅ | ✅ |
| Free tier | 25K records | Free (generous) | 25K operations |
| Pricing | Per-record + users | Per-dataset + API | Per-operation + users |
When to Use Each
Use Contentful if:
- Need a proven enterprise content platform with rich ecosystem
- Want REST and GraphQL API access
- Building content operations at scale with workflows
- Need comprehensive localization and role-based access
Use Sanity if:
- Want a fully customizable editing experience (Sanity Studio)
- Need real-time collaboration and live content queries
- Prefer code-defined content schemas over visual builders
- Want GROQ for powerful, expressive content queries
Use Hygraph if:
- Want a GraphQL-native CMS built entirely around GraphQL
- Need content federation to unify multiple content sources
- Prefer visual content modeling with a GraphQL playground
- Building projects where GraphQL is the primary API pattern
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on contentful v10.x, @sanity/client v6.x, and Hygraph GraphQL API.