Storyblok vs DatoCMS vs Prismic: Visual Headless CMS (2026)
TL;DR
Storyblok is the visual headless CMS — inline visual editor, nested components (bloks), REST and GraphQL APIs, real-time preview, multi-language. DatoCMS is the API-first headless CMS — structured content, real-time GraphQL API, image transformations, modular content blocks, localization. Prismic is the headless website builder — Slice Machine, slice-based content modeling, Next.js integration, content previews, Prismic client. In 2026: Storyblok for visual inline editing, DatoCMS for structured API-first content, Prismic for slice-based component content.
Key Takeaways
- Storyblok: storyblok ~20K weekly downloads — visual editor, nested bloks
- DatoCMS: datocms-client ~15K weekly downloads — GraphQL, structured content
- Prismic: @prismicio/client ~50K weekly downloads — Slice Machine, Next.js
- Storyblok provides the best inline visual editing experience
- DatoCMS offers the most powerful real-time GraphQL API
- Prismic has the tightest Next.js integration with Slice Machine
Storyblok
Storyblok — visual headless CMS:
Setup with Next.js
npm install @storyblok/react
// lib/storyblok.ts
import { storyblokInit, apiPlugin } from "@storyblok/react/rsc"
import PackageCard from "@/components/storyblok/PackageCard"
import PackageGrid from "@/components/storyblok/PackageGrid"
import HeroSection from "@/components/storyblok/HeroSection"
storyblokInit({
accessToken: process.env.STORYBLOK_ACCESS_TOKEN!,
use: [apiPlugin],
components: {
package_card: PackageCard,
package_grid: PackageGrid,
hero_section: HeroSection,
},
})
// app/[[...slug]]/page.tsx
import { StoryblokStory } from "@storyblok/react/rsc"
import { fetchData } from "@storyblok/react/rsc"
export default async function Page({ params }: { params: { slug?: string[] } }) {
const slug = params.slug?.join("/") || "home"
const { data } = await fetchData(`cdn/stories/${slug}`, {
version: "draft",
resolve_relations: "featured_packages.packages",
})
if (!data?.story) return notFound()
return <StoryblokStory story={data.story} />
}
// Generate static paths:
export async function generateStaticParams() {
const { data } = await fetchData("cdn/links", {
version: "published",
})
return Object.values(data.links)
.filter((link: any) => !link.is_folder)
.map((link: any) => ({
slug: link.slug.split("/"),
}))
}
Component mapping (bloks)
// components/storyblok/PackageCard.tsx
import { storyblokEditable } from "@storyblok/react/rsc"
export default function PackageCard({ blok }: { blok: any }) {
return (
<div {...storyblokEditable(blok)} className="rounded-xl border p-6">
<h3 className="text-xl font-bold">{blok.name}</h3>
<p className="text-gray-600 mt-1">{blok.description}</p>
<div className="mt-4 flex items-baseline gap-2">
<span className="text-3xl font-black text-blue-500">
{Number(blok.downloads).toLocaleString()}
</span>
<span className="text-sm text-gray-400">/week</span>
</div>
<span className="mt-3 inline-block rounded bg-green-100 px-2 py-1 text-xs text-green-700">
v{blok.version}
</span>
</div>
)
}
// components/storyblok/PackageGrid.tsx
import { StoryblokComponent, storyblokEditable } from "@storyblok/react/rsc"
export default function PackageGrid({ blok }: { blok: any }) {
return (
<section {...storyblokEditable(blok)} className="py-12">
<h2 className="text-3xl font-bold text-center mb-8">{blok.title}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
{blok.packages?.map((nestedBlok: any) => (
<StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</div>
</section>
)
}
Content delivery API
import StoryblokClient from "storyblok-js-client"
const client = new StoryblokClient({
accessToken: process.env.STORYBLOK_ACCESS_TOKEN!,
})
// Fetch stories:
const { data } = await client.get("cdn/stories", {
version: "published",
starts_with: "packages/",
sort_by: "content.downloads:desc",
per_page: 20,
})
// Fetch single story:
const { data: story } = await client.get("cdn/stories/packages/react", {
version: "published",
resolve_relations: "author,tags",
})
// Search:
const { data: results } = await client.get("cdn/stories", {
version: "published",
search_term: "react",
filter_query: {
downloads: { gt_int: 1000000 },
component: { in: "package_card" },
},
})
// Fetch datasource (for dropdowns, categories):
const { data: tags } = await client.get("cdn/datasource_entries", {
datasource: "package-tags",
})
DatoCMS
DatoCMS — API-first headless CMS:
Setup with Next.js
npm install datocms-client react-datocms
// lib/datocms.ts
import { GraphQLClient } from "graphql-request"
export function createDatoCMSClient(preview = false) {
return new GraphQLClient("https://graphql.datocms.com/", {
headers: {
Authorization: `Bearer ${process.env.DATOCMS_API_TOKEN}`,
...(preview && { "X-Include-Drafts": "true" }),
},
})
}
// Reusable query function:
export async function query<T>(
queryStr: string,
variables?: Record<string, any>,
preview = false
): Promise<T> {
const client = createDatoCMSClient(preview)
return client.request<T>(queryStr, variables)
}
GraphQL queries
import { gql } from "graphql-request"
import { query } from "@/lib/datocms"
// Fetch packages:
const data = await query<{ allPackages: Package[] }>(gql`
query GetPackages($first: IntType!, $skip: IntType) {
allPackages(first: $first, skip: $skip, orderBy: downloads_DESC) {
id
name
slug
description
downloads
version
tags {
name
slug
}
author {
name
avatar {
responsiveImage(imgixParams: { w: 48, h: 48, fit: crop }) {
src
width
height
alt
}
}
}
_publishedAt
}
_allPackagesMeta {
count
}
}
`, { first: 20, skip: 0 })
// Search:
const results = await query<{ allPackages: Package[] }>(gql`
query SearchPackages($query: String!) {
allPackages(
filter: {
OR: [
{ name: { matches: { pattern: $query } } },
{ description: { matches: { pattern: $query } } },
]
downloads: { gt: 100000 }
}
orderBy: downloads_DESC
first: 10
) {
id
name
description
downloads
version
}
}
`, { query: "react" })
Responsive images
// components/PackageCard.tsx
import { Image as DatoImage } from "react-datocms"
interface PackageCardProps {
package: {
name: string
description: string
downloads: number
version: string
logo: {
responsiveImage: any
}
}
}
export function PackageCard({ package: pkg }: PackageCardProps) {
return (
<div className="rounded-xl border p-6">
{pkg.logo?.responsiveImage && (
<DatoImage
data={pkg.logo.responsiveImage}
className="w-16 h-16 rounded-lg mb-4"
/>
)}
<h3 className="text-xl font-bold">{pkg.name}</h3>
<p className="text-gray-600 mt-1 line-clamp-2">{pkg.description}</p>
<div className="mt-4">
<span className="text-3xl font-black text-blue-500">
{pkg.downloads.toLocaleString()}
</span>
<span className="text-sm text-gray-400 ml-1">/week</span>
</div>
</div>
)
}
// DatoCMS responsive image query fragment:
const IMAGE_FRAGMENT = gql`
fragment ResponsiveImage on FileField {
responsiveImage(
imgixParams: {
w: 400
h: 300
fit: crop
auto: format
}
) {
src
srcSet
width
height
alt
title
base64
}
}
`
Structured text (rich content)
import { StructuredText, renderNodeRule } from "react-datocms"
import { isCode, isHeading } from "datocms-structured-text-utils"
function ArticlePage({ article }: { article: any }) {
return (
<article>
<h1>{article.title}</h1>
<StructuredText
data={article.content}
customNodeRules={[
renderNodeRule(isCode, ({ node, key }) => (
<pre key={key} className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<code className={`language-${node.language}`}>
{node.code}
</code>
</pre>
)),
renderNodeRule(isHeading, ({ node, children, key }) => {
const Tag = `h${node.level}` as keyof JSX.IntrinsicElements
return <Tag key={key} className="font-bold mt-8 mb-4">{children}</Tag>
}),
]}
renderBlock={({ record }) => {
if (record.__typename === "PackageCardRecord") {
return <PackageCard package={record} />
}
return null
}}
/>
</article>
)
}
Real-time updates
// Real-time content updates with DatoCMS:
import { useQuerySubscription } from "react-datocms"
function LivePackageList() {
const { data, status } = useQuerySubscription({
query: `
query {
allPackages(orderBy: downloads_DESC, first: 10) {
id
name
downloads
version
}
}
`,
token: process.env.NEXT_PUBLIC_DATOCMS_API_TOKEN!,
preview: true,
})
if (status === "connecting") return <div>Connecting...</div>
return (
<ul>
{data?.allPackages.map((pkg: any) => (
<li key={pkg.id}>
{pkg.name} — {pkg.downloads.toLocaleString()} downloads
</li>
))}
</ul>
)
}
Prismic
Prismic — headless website builder:
Setup with Slice Machine
npx @slicemachine/init@latest
# This creates:
# - slicemachine.config.json
# - customtypes/ — content models
# - slices/ — React slice components
// prismicio.ts
import * as prismic from "@prismicio/client"
import * as prismicNext from "@prismicio/next"
import sm from "./slicemachine.config.json"
export const repositoryName = sm.repositoryName
export function createClient(config: prismicNext.CreateClientConfig = {}) {
const client = prismic.createClient(repositoryName, {
routes: [
{ type: "page", path: "/:uid" },
{ type: "page", uid: "home", path: "/" },
{ type: "blog_post", path: "/blog/:uid" },
],
...config,
})
prismicNext.enableAutoPreviews({ client, previewData: config.previewData })
return client
}
Page routing
// app/[[...uid]]/page.tsx
import { SliceZone } from "@prismicio/react"
import { createClient } from "@/prismicio"
import { components } from "@/slices"
export default async function Page({ params }: { params: { uid?: string[] } }) {
const client = createClient()
const uid = params.uid?.join("/") || "home"
const page = await client.getByUID("page", uid).catch(() => notFound())
return (
<SliceZone slices={page.data.slices} components={components} />
)
}
export async function generateStaticParams() {
const client = createClient()
const pages = await client.getAllByType("page")
return pages.map((page) => ({
uid: page.uid === "home" ? undefined : [page.uid!],
}))
}
export async function generateMetadata({ params }: { params: { uid?: string[] } }) {
const client = createClient()
const uid = params.uid?.join("/") || "home"
const page = await client.getByUID("page", uid)
return {
title: page.data.meta_title,
description: page.data.meta_description,
}
}
Slices (components)
// slices/PackageShowcase/index.tsx
import { Content } from "@prismicio/client"
import { PrismicRichText, SliceComponentProps } from "@prismicio/react"
export type PackageShowcaseProps = SliceComponentProps<Content.PackageShowcaseSlice>
export default function PackageShowcase({ slice }: PackageShowcaseProps) {
return (
<section className="py-12" data-slice-type={slice.slice_type}>
<div className="max-w-6xl mx-auto">
<PrismicRichText
field={slice.primary.title}
components={{
heading2: ({ children }) => (
<h2 className="text-3xl font-bold text-center mb-8">{children}</h2>
),
}}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{slice.items.map((item, i) => (
<div key={i} className="rounded-xl border p-6">
<h3 className="text-xl font-bold">
{item.package_name}
</h3>
<PrismicRichText field={item.description} />
<div className="mt-4">
<span className="text-3xl font-black text-blue-500">
{Number(item.downloads).toLocaleString()}
</span>
<span className="text-sm text-gray-400 ml-1">/week</span>
</div>
<span className="mt-2 inline-block rounded bg-green-100 px-2 py-1 text-xs text-green-700">
v{item.version}
</span>
</div>
))}
</div>
</div>
</section>
)
}
Client API queries
import { createClient } from "@/prismicio"
const client = createClient()
// Fetch all documents of a type:
const packages = await client.getAllByType("package", {
orderings: [{ field: "my.package.downloads", direction: "desc" }],
pageSize: 20,
})
// Fetch by UID:
const pkg = await client.getByUID("package", "react")
// Query with predicates:
import * as prismic from "@prismicio/client"
const results = await client.get({
filters: [
prismic.filter.at("document.type", "package"),
prismic.filter.numberGreaterThan("my.package.downloads", 1000000),
prismic.filter.fulltext("document", "react"),
],
orderings: [{ field: "my.package.downloads", direction: "desc" }],
pageSize: 10,
})
// Fetch with linked documents:
const post = await client.getByUID("blog_post", "react-comparison", {
fetchLinks: ["package.name", "package.downloads", "package.version"],
})
Content preview
// app/api/preview/route.ts
import { redirectToPreviewURL } from "@prismicio/next"
import { createClient } from "@/prismicio"
export async function GET(request: Request) {
const client = createClient()
return await redirectToPreviewURL({ client, request })
}
// app/api/exit-preview/route.ts
import { exitPreview } from "@prismicio/next"
export async function GET() {
return await exitPreview()
}
Feature Comparison
| Feature | Storyblok | DatoCMS | Prismic |
|---|---|---|---|
| API type | REST + GraphQL | GraphQL (real-time) | REST |
| Visual editor | ✅ (inline editing) | ❌ (structured only) | ✅ (Slice Machine) |
| Content modeling | Bloks (nested) | Modular content | Slices |
| Rich text | Rich text field | Structured Text | Rich Text / Slices |
| Real-time API | Preview only | ✅ (subscriptions) | Preview only |
| Image CDN | ✅ (imgix) | ✅ (imgix) | ✅ (imgix) |
| Responsive images | Via parameters | ✅ (react-datocms) | Via parameters |
| Localization | ✅ (field-level) | ✅ (field-level) | ✅ (document-level) |
| Webhooks | ✅ | ✅ | ✅ |
| Environments | ✅ | ✅ (sandbox) | ❌ |
| Roles/permissions | ✅ | ✅ | ✅ |
| TypeScript SDK | ✅ | ✅ | ✅ |
| Next.js integration | ✅ | ✅ | ✅ (Slice Machine) |
| Component registry | ✅ (bloks) | ❌ | ✅ (slices) |
| Free tier | ✅ (1 user) | ✅ (generous) | ✅ (1 user) |
| Pricing | Per-seat | Per-record | Per-seat |
When to Use Each
Use Storyblok if:
- Want the best inline visual editing experience for content teams
- Need deeply nested component structures (bloks within bloks)
- Building multi-language sites with field-level localization
- Prefer real-time visual preview while editing content
Use DatoCMS if:
- Want a powerful real-time GraphQL API with subscriptions
- Need structured content with modular blocks and image transformations
- Prefer API-first development with strong TypeScript support
- Building content-heavy sites where developer experience is priority
Use Prismic if:
- Want the tightest Next.js integration with Slice Machine
- Prefer component-based content modeling (slices)
- Need a visual slice builder for iterating on page layouts
- Building marketing sites where content editors assemble pages from slices
Methodology
Download data from npm registry (weekly average, March 2026). Feature comparison based on @storyblok/react v3.x, react-datocms v6.x, and @prismicio/client v7.x.