Skip to main content

Contentful vs Sanity vs Hygraph: Enterprise Headless CMS (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.