Skip to main content

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

CMSTypeFree TierSelf-HostedNext.js DXTypeScript
SanityManaged3 users, 5GBExcellent
ContentfulManaged25K recordsGood✅ (CLI)
PayloadSelf-hostedFreeExcellent✅ Native
KeystaticGit-backedFreeGood
StrapiSelf-hostedFreeGood

When to Choose

ScenarioPick
Startup, great DX, low costSanity
Enterprise, compliance requirementsContentful
No vendor lock-in, self-hostedPayload CMS
Content in Git, no databaseKeystatic
Marketing team needs CMS accessSanity or Contentful
SaaS app with user-generated contentPayload (code your own rules)

Compare CMS package health on PkgPulse.

Comments

Stay Updated

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