Payload Starter vs Sanity Starter vs Strapi Starter
·StarterPick Team
payload-cmssanitystrapicmssaas-boilerplatenext-js2026
TL;DR
Payload 3.0 is the most compelling CMS for Next.js developers in 2026 — it runs inside your Next.js app with no separate server. Sanity is the best cloud-hosted CMS for content teams (real-time collaboration, asset management, GROQ queries). Strapi is the self-hosted workhorse with REST and GraphQL APIs. For developer-run SaaS products: Payload is the new default. For content-heavy sites where non-technical editors work: Sanity. For REST/GraphQL API teams: Strapi.
Key Takeaways
- Payload 3.0: Next.js App Router native, TypeScript-first, admin panel = your Next.js app, ~50K downloads/week
- Sanity: Cloud-hosted GROQ queries, real-time collaboration,
@sanity/next-loader, 250K downloads/week - Strapi 5: Self-hosted, REST + GraphQL auto-generated, drag-and-drop content types, 400K downloads/week
- Database: Payload → your Postgres/MongoDB; Sanity → Sanity cloud; Strapi → SQLite/Postgres/MySQL
- Auth: Payload has built-in auth; Sanity uses Sanity user management; Strapi has built-in auth
- For SaaS: Payload (admin panel doubles as CMS = one app to deploy)
Payload 3.0: CMS Inside Next.js
Payload 3.0's paradigm shift: the CMS and your app are the same Next.js project. The admin panel is a Next.js route (/admin), not a separate service.
# New project with Payload:
npx create-payload-app@latest
# Or add to existing Next.js:
npm install payload @payloadcms/next @payloadcms/richtext-lexical
// payload.config.ts — your CMS schema in TypeScript:
import { buildConfig } from 'payload';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
export default buildConfig({
secret: process.env.PAYLOAD_SECRET!,
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
}),
email: nodemailerAdapter({
defaultFromAddress: 'hello@myapp.com',
defaultFromName: 'My App',
transportOptions: { host: 'smtp.resend.com', auth: { pass: process.env.RESEND_API_KEY } },
}),
admin: {
user: 'users', // 'users' collection handles admin auth too
importMap: { baseDir: path.resolve(dirname) },
},
collections: [
// Users with auth (SaaS-ready):
{
slug: 'users',
auth: {
verify: true,
forgotPassword: { generateEmailHTML: ({ token }) => `<a href="/reset?token=${token}">Reset</a>` },
},
fields: [
{ name: 'name', type: 'text' },
{ name: 'plan', type: 'select', options: ['free', 'pro', 'team'], defaultValue: 'free' },
{ name: 'stripeCustomerId', type: 'text', admin: { hidden: true } },
],
},
// Blog posts:
{
slug: 'posts',
admin: { useAsTitle: 'title' },
access: {
read: () => true, // Public
create: ({ req }) => !!req.user, // Auth required
update: ({ req, id }) => req.user?.id === id,
delete: ({ req }) => req.user?.roles?.includes('admin'),
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true },
{ name: 'content', type: 'richText', editor: lexicalEditor() },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'publishedAt', type: 'date' },
{ name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft' },
],
versions: { drafts: true }, // Draft/publish workflow
},
],
plugins: [],
});
// Querying in Next.js Server Component:
import { getPayload } from 'payload';
import config from '@payload-config';
export async function getPosts() {
const payload = await getPayload({ config });
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 10,
depth: 1, // Resolve relationships
});
return posts.docs;
}
// In page.tsx:
export default async function BlogPage() {
const posts = await getPosts(); // Direct DB query, no HTTP overhead
return <PostList posts={posts} />;
}
Sanity: Cloud-First with GROQ
npm install next-sanity @sanity/image-url @sanity/cli
npx sanity@latest init
// sanity.config.ts:
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemas';
export default defineConfig({
name: 'default',
title: 'My SaaS Blog',
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
});
// schemas/post.ts — Sanity schema:
import { defineField, defineType } from 'sanity';
export const postType = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (r) => r.required() }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
defineField({ name: 'publishedAt', type: 'datetime' }),
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }],
}),
],
preview: {
select: { title: 'title', author: 'author.name', media: 'mainImage' },
},
});
// Querying with GROQ (Sanity's query language):
import { createClient } from 'next-sanity';
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: true, // CDN for reads, real-time for writes
});
// GROQ query:
const posts = await client.fetch(`
*[_type == "post" && !(_id in path("drafts.**"))] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
"author": author->{ name, "avatar": image.asset->url },
"estimatedReadingTime": round(length(pt::text(body)) / 5 / 180)
}[0...10]
`);
Strapi 5: Self-Hosted API CMS
npx create-strapi-app@latest my-project
# Prompts: Database (SQLite for dev, Postgres for prod)
// Content type builder: drag-and-drop UI at /admin
// Or via code (src/api/blog-post/content-types/blog-post/schema.json):
{
"kind": "collectionType",
"collectionName": "blog_posts",
"info": { "singularName": "blog-post", "pluralName": "blog-posts" },
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title" },
"content": { "type": "richtext" },
"publishedAt": { "type": "datetime" },
"author": { "type": "relation", "relation": "manyToOne", "target": "plugin::users-permissions.user" }
}
}
// Fetching from Strapi in Next.js:
const posts = await fetch(
`${process.env.STRAPI_URL}/api/blog-posts?populate=author&sort=publishedAt:desc&pagination[limit]=10`,
{ headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` } }
).then(r => r.json());
Comparison Table
| Payload 3.0 | Sanity | Strapi 5 | |
|---|---|---|---|
| Hosting | Self-hosted (in your app) | Cloud | Self-hosted |
| Database | Postgres/MongoDB (your DB) | Sanity cloud | SQLite/Postgres/MySQL |
| Next.js integration | Native (same process) | HTTP + CDN | HTTP API |
| Auth | ✅ Built-in users/auth | Sanity users | ✅ Users + permissions |
| Real-time | Via webhooks | ✅ Real-time collab | Limited |
| GROQ/filtering | Payload query API | GROQ (powerful) | REST + filters |
| Asset management | S3/local | Sanity Asset Pipeline | Upload providers |
| SaaS use case | ✅ Admin panel = CMS | Partial | Partial |
| Price | Free (self-hosted) | Free tier, $99+/mo | Free (self-hosted) |
| Community | Growing | Large | Large |
Decision Guide
Choose Payload if:
→ Building a Next.js SaaS (admin = CMS, one deployment)
→ Want TypeScript-first schema definition
→ Need auth + CMS in one system
→ Self-hosted is required
Choose Sanity if:
→ Content team needs real-time collaboration
→ Rich media/asset management is important
→ Content-heavy site (not SaaS admin)
→ Okay with cloud dependency (SaaS pricing)
Choose Strapi if:
→ Need REST or GraphQL API auto-generated
→ Non-technical editors comfortable with Strapi UI
→ Multi-framework consumers (mobile + web)
→ Self-hosted with traditional SQL database
Find CMS-powered starters and boilerplates at StarterPick.