Best CMS Solutions for Next.js in 2026
·PkgPulse Team
TL;DR
Sanity for developer-first flexibility; Contentful for enterprise; Payload for self-hosted TypeScript control. Sanity (~600K weekly downloads) is the developer's CMS — real-time collaboration, GROQ query language, and React-based studio. Contentful (~400K) is the established enterprise choice. Payload CMS (~200K, fast-growing) is TypeScript-native, self-hosted, and generates its own admin UI from your schema — zero vendor lock-in.
Key Takeaways
- Sanity: ~600K weekly downloads — GROQ queries, real-time, React studio, free tier
- Contentful: ~400K downloads — enterprise feature set, established market leader
- Payload CMS: ~200K downloads — self-hosted, TypeScript-first, admin UI auto-generated
- Hygraph (formerly GraphCMS) — GraphQL-native, strong federation
- Keystatic — Git-backed CMS, no database needed, content in your repo
Sanity + Next.js
// Sanity — schema definition
// schemas/post.ts
export default {
name: 'post',
title: 'Blog Post',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
},
{
name: 'content',
title: 'Content',
type: 'array',
of: [
{ type: 'block' }, // Portable Text
{ type: 'image' },
{ type: 'code' }, // code-input plugin
],
},
{
name: 'publishedAt',
type: 'datetime',
},
],
};
// Sanity — GROQ queries in Next.js
import { createClient } from 'next-sanity';
import { groq } from 'next-sanity';
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: true,
});
// GROQ — Sanity's query language
const POSTS_QUERY = groq`
*[_type == "post" && !(_id in path('drafts.**'))] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
"author": author->{ name, "image": image.asset->url },
"coverImage": mainImage.asset->url,
excerpt,
}
`;
// Fetch in Next.js App Router
async function getPosts() {
return client.fetch(POSTS_QUERY);
}
// app/blog/page.tsx
export default async function BlogPage() {
const posts = await getPosts();
return (
<div>
{posts.map(post => (
<article key={post._id}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
// Sanity — live preview with Draft Mode
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get('secret');
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(searchParams.get('slug') ?? '/');
}
Contentful + Next.js
// Contentful — typed content fetching
import contentful from 'contentful';
const client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
// For preview:
// host: 'preview.contentful.com',
// accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
});
// TypeScript types from Contentful CLI
import type { TypeBlogPost } from '@/contentful/types';
async function getBlogPosts(): Promise<TypeBlogPost[]> {
const entries = await client.getEntries<TypeBlogPost>({
content_type: 'blogPost',
order: ['-fields.publishDate'],
limit: 10,
'fields.publishDate[lte]': new Date().toISOString(),
});
return entries.items;
}
// Contentful — webhook revalidation (Next.js ISR)
// app/api/contentful-webhook/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const secret = req.headers.get('x-contentful-webhook-secret');
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const body = await req.json();
const contentType = body.sys.contentType?.sys.id;
if (contentType === 'blogPost') {
revalidateTag('blog-posts');
revalidatePath('/blog');
}
return new Response('OK');
}
Payload CMS (Self-Hosted)
// Payload — collection schema (TypeScript-first)
// collections/Posts.ts
import type { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'publishedAt'],
},
access: {
read: () => true, // Public
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, required: true },
{
name: 'content',
type: 'richText', // Lexical rich text editor
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
{ name: 'publishedAt', type: 'date' },
],
hooks: {
beforeChange: [
({ data }) => {
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
},
],
},
};
// Payload — REST API auto-generated
// GET /api/posts?where[status][equals]=published&sort=-publishedAt
// GET /api/posts/:id
// POST /api/posts (authenticated)
// Next.js App Router + Payload
// payload.config.ts
import { buildConfig } from 'payload';
import { Posts } from './collections/Posts';
import { Users } from './collections/Users';
export default buildConfig({
collections: [Posts, Users],
secret: process.env.PAYLOAD_SECRET!,
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
}),
// Admin UI served at /admin — auto-generated from schema
admin: {
user: 'users',
},
});
Keystatic (Git-Backed)
// Keystatic — content stored in your Git repo (no database)
// keystatic.config.ts
import { config, collection, fields } from '@keystatic/core';
export default config({
storage: {
kind: 'github', // 'local' for dev, 'github' for prod
repo: 'myorg/mysite',
},
collections: {
posts: collection({
label: 'Blog Posts',
slugField: 'title',
path: 'content/blog/*',
format: { contentField: 'content' },
schema: {
title: fields.slug({ name: { label: 'Title' } }),
publishedAt: fields.date({ label: 'Published At' }),
content: fields.markdoc({ label: 'Content' }),
},
}),
},
});
Comparison Table
| CMS | Type | Free Tier | Self-Hosted | Next.js DX | TypeScript |
|---|---|---|---|---|---|
| Sanity | Managed | 3 users, 5GB | ❌ | Excellent | ✅ |
| Contentful | Managed | 25K records | ❌ | Good | ✅ (CLI) |
| Payload | Self-hosted | Free | ✅ | Excellent | ✅ Native |
| Keystatic | Git-backed | Free | ✅ | Good | ✅ |
| Strapi | Self-hosted | Free | ✅ | Good | ✅ |
When to Choose
| Scenario | Pick |
|---|---|
| Startup, great DX, low cost | Sanity |
| Enterprise, compliance requirements | Contentful |
| No vendor lock-in, self-hosted | Payload CMS |
| Content in Git, no database | Keystatic |
| Marketing team needs CMS access | Sanity or Contentful |
| SaaS app with user-generated content | Payload (code your own rules) |
Compare CMS package health on PkgPulse.
See the live comparison
View sanity vs. contentful on PkgPulse →