Skip to main content

Guide

Contentful vs Sanity vs Hygraph 2026

Compare Contentful, Sanity, and Hygraph for enterprise headless CMS. Content modeling, APIs, real-time collaboration, and which headless CMS to use in 2026.

·PkgPulse Team·
0

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

FeatureContentfulSanityHygraph
APIREST + GraphQLGROQ + GraphQLGraphQL (native)
Query languageREST filters / GraphQLGROQ (powerful)GraphQL
Content modelingVisual + APICode (schemas)Visual UI
Rich textContentful Rich TextPortable TextRich Text (AST)
Real-timeWebhooks✅ (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 tier25K recordsFree (generous)25K operations
PricingPer-record + usersPer-dataset + APIPer-operation + users

Selecting an Enterprise Headless CMS

Enterprise headless CMS selection is a multi-year commitment that affects content operations teams, developers, and editorial workflows simultaneously. The technical API is only one dimension of the decision — equally important are the non-technical considerations: how quickly can a new content editor become productive in the CMS interface, how many API calls does a typical page fetch require, and how does the pricing scale as content volume and team size grow. Contentful's per-record pricing becomes significant at large content volumes, while Sanity's per-dataset-plus-API-usage pricing scales more predictably. Hygraph's per-operation pricing requires careful estimation of query volumes before committing. Request pricing estimates for your specific usage patterns from all three vendors before making a final decision — published pricing tiers rarely reflect what enterprise customers actually pay.

Production Caching and CDN Architecture

Headless CMS content delivery to production applications requires a caching layer that accounts for content publish latency and cache invalidation. Contentful's CDN (powered by Fastly) serves content within 200ms globally, but the accessToken in client-side code is visible to anyone who inspects network requests — use the preview API token only in server-side rendering contexts and restrict it via environment variables. Sanity's CDN uses useCdn: true for cached reads, with fresh data available via the API without the CDN flag. For Next.js ISR, Sanity's webhook integration enables on-demand ISR revalidation — when an editor publishes content, Sanity fires a webhook that triggers Next.js to revalidate the specific page, combining static performance with near-instant content freshness. Hygraph's content delivery uses a global CDN with configurable cache headers, and its webhook system triggers on both stage changes (draft → published) and field-level updates.

Content Modeling Complexity

The content modeling experience differs markedly between the three platforms. Contentful's content type editor is the most mature, with fine-grained field validation rules (min/max string length, regex patterns, allowed values) that enforce data quality at the CMS level rather than in application code. Sanity's code-defined schemas (TypeScript or JavaScript objects exported from schema files) are the most flexible — any validation logic that can be expressed in JavaScript can be a schema validation rule. The tradeoff is that schema changes require deploying code rather than clicking a UI, which is faster for developers but requires coordination between Sanity Studio deployments and content type changes. Hygraph's visual content modeler is the fastest for non-technical users to understand but the least flexible for complex custom field types.

TypeScript Integration

Sanity's TypeScript integration improved dramatically with the introduction of @sanity/client's typed fetch and the sanity-codegen ecosystem. GROQ query results are typed based on the query's projection — tools like groq-js and @sanity/groq-js can statically analyze GROQ queries to infer return types. Contentful's contentful npm package includes TypeScript declarations for the Delivery API, but the entry.fields object is typed as Record<string, any> without schema-aware types — use contentful-typescript-codegen to generate precise types from your Contentful content model. Hygraph's GraphQL-native approach means using graphql-codegen directly against the Hygraph endpoint to generate fully typed query hooks for React — the same tooling used for any GraphQL API.

Real-Time Collaboration and Editorial Workflows

Sanity's real-time collaboration is the most technically advanced of the three: multiple editors can work on the same document simultaneously with operational transformation (OT) ensuring conflict-free merging, similar to Google Docs. Cursor positions and selections of other users are visible in real time. Contentful's collaboration model is lock-based — a document can be edited by one user at a time, with a visible lock indicator showing who currently has the document checked out. Hygraph supports concurrent editing but without the real-time cursor visibility of Sanity. For editorial teams where multiple writers and editors frequently work on the same content simultaneously, Sanity's collaboration model significantly reduces the "I can't edit this, someone else has it locked" friction.

Migration and Vendor Lock-in Assessment

Migrating away from any headless CMS is a significant undertaking that should factor into initial platform selection. Contentful's content export tools produce JSON data that is moderately portable, but rich text content stored in Contentful's proprietary rich text format requires transformation to render in other systems. Sanity's Portable Text format is an open specification with renderers for React, Vue, and other frameworks, and Sanity's export API produces standard JSON — migration is technically feasible though still labor-intensive. Hygraph's GraphQL API makes bulk content export straightforward using standard GraphQL query tooling, but the rich text AST format is GraphCMS-specific. For organizations with strict vendor lock-in policies, self-hosted CMS platforms like Payload CMS or Directus may be preferable.

Localization and Multi-Language Content Workflows

Enterprise content teams frequently need to manage content across multiple locales, and the headless CMS platforms handle this differently. Contentful's localization model is field-level — each field in a content type can be marked as localizable, and the content API returns locale-specific values when the locale parameter is passed. Managing translations in Contentful requires either editorial staff creating entries per locale in the UI or integrating a third-party translation service via Contentful's Marketplace integrations. Sanity's localization is schema-based using the @sanity/document-internationalization plugin, which creates one document per locale with references linking translated documents together — this approach makes it straightforward to query all translations of a document and compare them side-by-side in the Studio. Hygraph's localization model closely resembles Contentful's field-level approach, with built-in locale management in the schema builder and the Content API supporting locale filtering natively. For large translation projects with external translation agencies, evaluate each CMS's integration with translation management systems like Phrase, Lokalise, or Crowdin to ensure the editorial workflow supports your team's translation process.

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.

Compare CMS platforms and developer tooling on PkgPulse →

Compare Contentful and Sanity package health on PkgPulse.

See also: AVA vs Jest and Storyblok vs DatoCMS vs Prismic, Builder.io vs Plasmic vs Makeswift.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.