Payload CMS v3 vs Keystatic vs Outstatic: Headless CMS 2026
Payload CMS v3 vs Keystatic vs Outstatic: Headless CMS 2026
TL;DR
The Next.js developer CMS space has split into two clear camps: git-based CMS (content stored in your repo as MDX/YAML) and database-backed CMS (content in Postgres/MongoDB with a proper API). Payload CMS v3 is the database-backed power option — fully TypeScript, runs inside your Next.js app as a route handler (no separate server), with a schema-driven admin UI, access control, relationship fields, and REST+GraphQL APIs out of the box. Keystatic is the git-based local-first option from Thinkmill — content stored as YAML/JSON/MDX in your repo, edited through a beautiful local UI at /keystatic, zero backend required, supports GitHub mode for team editing. Outstatic is the simplest git-based option — a GitHub-connected CMS that commits content directly to your repo via GitHub API, dead simple to set up with zero infrastructure. For full-featured content management with auth, workflows, and complex data: Payload v3. For developer blogs and docs sites with content in git: Keystatic. For the simplest possible CMS without leaving GitHub: Outstatic.
Key Takeaways
- Payload v3 runs inside Next.js — no separate API server, routes handled by App Router
- Payload has access control — field-level, collection-level, and draft/publish workflows
- Keystatic is git-native — content is YAML/MDX files in your repo, versioned with your code
- Keystatic has two modes — local (dev machine) and GitHub (team via GitHub API)
- Outstatic uses GitHub API — no local setup, content commits go directly to your repo
- Payload uses a real database — Postgres (Drizzle ORM) or MongoDB in v3
- Keystatic is zero-backend — no database, no auth layer, content in files
Architecture Comparison
Payload CMS v3 Keystatic Outstatic
────────────────── ────────────────── ──────────────────
Database (Postgres) Git repo (files) Git repo (GitHub)
Next.js route handler Next.js route handler Next.js route handler
Admin UI at /admin UI at /keystatic UI at /outstatic
REST + GraphQL API Local file reads GitHub API
TypeScript schemas TypeScript schemas Config object
Auth + access control GitHub auth GitHub OAuth
Draft/publish None (git branch) None (git branch)
Relationships References (no joins) No relationships
Payload CMS v3: Database-Backed CMS in Next.js
Payload v3 rewrote the architecture to embed directly into Next.js App Router — the admin UI and API routes live inside your Next.js app with no separate Payload server.
Installation
npx create-payload-app@latest
# Or add to existing Next.js app:
npm install payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical
Project Structure
my-app/
├── src/
│ ├── app/
│ │ ├── (payload)/
│ │ │ ├── admin/
│ │ │ │ └── [[...segments]]/
│ │ │ │ └── page.tsx # Admin UI
│ │ │ └── api/
│ │ │ └── [...slug]/
│ │ │ └── route.ts # REST + GraphQL API
│ │ └── (site)/
│ │ └── blog/
│ │ └── [slug]/page.tsx # Your frontend
│ ├── collections/
│ │ ├── Posts.ts
│ │ ├── Users.ts
│ │ └── Media.ts
│ └── payload.config.ts
Payload Config
// src/payload.config.ts
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import { Posts } from "./collections/Posts";
import { Users } from "./collections/Users";
import { Media } from "./collections/Media";
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Posts, Users, Media],
editor: lexicalEditor({}),
secret: process.env.PAYLOAD_SECRET ?? "",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL,
},
}),
sharp,
});
Collection Definition
// src/collections/Posts.ts
import type { CollectionConfig } from "payload";
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "publishedAt", "author"],
},
access: {
// Everyone can read published posts
read: ({ req }) => {
if (req.user) return true; // Admins see all
return { status: { equals: "published" } };
},
create: ({ req }) => Boolean(req.user), // Must be logged in
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.role === "admin",
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
{
name: "status",
type: "select",
options: ["draft", "published", "archived"],
defaultValue: "draft",
admin: {
position: "sidebar",
},
},
{
name: "publishedAt",
type: "date",
admin: {
position: "sidebar",
condition: (data) => data.status === "published",
},
},
{
name: "author",
type: "relationship",
relationTo: "users",
required: true,
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
},
{
name: "tags",
type: "array",
fields: [
{
name: "tag",
type: "text",
},
],
},
{
name: "content",
type: "richText",
},
{
name: "excerpt",
type: "textarea",
maxLength: 300,
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.status === "published" && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
},
],
},
versions: {
drafts: true,
maxPerDoc: 10,
},
};
Querying Content in Next.js
// app/(site)/blog/page.tsx — Server Component
import { getPayload } from "payload";
import configPromise from "@payload-config";
export default async function BlogPage() {
const payload = await getPayload({ config: configPromise });
// Type-safe query — generated types from payload-types.ts
const posts = await payload.find({
collection: "posts",
where: {
status: { equals: "published" },
},
sort: "-publishedAt",
limit: 20,
depth: 1, // Resolve author relationship
});
return (
<div>
{posts.docs.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<span>By {typeof post.author === "object" ? post.author.name : "Unknown"}</span>
</article>
))}
</div>
);
}
REST API
// Fetch from external services — REST API at /api/posts
const response = await fetch("https://mysite.com/api/posts?where[status][equals]=published&limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAYLOAD_API_KEY}`,
},
});
const { docs } = await response.json();
Keystatic: Git-Based Local-First CMS
Keystatic stores all content as YAML/JSON/MDX files in your repository — no database, no separate service. Content is versioned alongside your code.
Installation
npm install @keystatic/core @keystatic/next
Keystatic Config
// keystatic.config.ts
import { config, collection, fields, singleton } from "@keystatic/core";
export default config({
storage: {
kind: "local", // or "github" for team editing
},
// Optional: GitHub mode for team collaboration
// storage: {
// kind: "github",
// repo: { owner: "your-org", name: "your-repo" },
// },
collections: {
posts: collection({
label: "Blog Posts",
slugField: "title",
path: "content/posts/*",
format: { contentField: "content" },
schema: {
title: fields.slug({ name: { label: "Title" } }),
publishedAt: fields.date({
label: "Published Date",
validation: { isRequired: true },
}),
status: fields.select({
label: "Status",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
}),
tags: fields.array(fields.text({ label: "Tag" }), {
label: "Tags",
itemLabel: (props) => props.fields.value.value,
}),
featuredImage: fields.image({
label: "Featured Image",
directory: "public/images/posts",
publicPath: "/images/posts",
}),
content: fields.markdoc({
label: "Content",
extension: "md",
}),
excerpt: fields.text({
label: "Excerpt",
multiline: true,
validation: { length: { max: 300 } },
}),
},
}),
authors: collection({
label: "Authors",
slugField: "name",
path: "content/authors/*",
schema: {
name: fields.slug({ name: { label: "Name" } }),
bio: fields.text({ label: "Bio", multiline: true }),
avatar: fields.image({
label: "Avatar",
directory: "public/images/authors",
publicPath: "/images/authors",
}),
twitter: fields.text({ label: "Twitter Handle" }),
},
}),
},
singletons: {
siteConfig: singleton({
label: "Site Config",
path: "content/site-config",
schema: {
siteName: fields.text({ label: "Site Name" }),
description: fields.text({ label: "Description", multiline: true }),
featuredPosts: fields.array(
fields.relationship({
label: "Post",
collection: "posts",
}),
{ label: "Featured Posts" }
),
},
}),
},
});
Next.js Route Handlers
// app/keystatic/layout.tsx
import KeystaticApp from "./keystatic";
export default function Layout() {
return <KeystaticApp />;
}
// app/keystatic/keystatic.tsx
"use client";
import { makePage } from "@keystatic/next/ui/app";
import config from "../../../keystatic.config";
export default makePage(config);
// app/api/keystatic/[...params]/route.ts
import { makeRouteHandler } from "@keystatic/next/route-handler";
import config from "../../../../keystatic.config";
export const { POST, GET } = makeRouteHandler({ config });
Reading Content in Next.js
// app/blog/page.tsx — Server Component
import { createReader } from "@keystatic/core/reader";
import keystaticConfig from "../../../keystatic.config";
const reader = createReader(process.cwd(), keystaticConfig);
export default async function BlogPage() {
const posts = await reader.collections.posts.all();
// Filter published posts
const published = posts.filter(
(post) => post.entry.status === "published"
);
return (
<div>
{published.map((post) => (
<article key={post.slug}>
<h2>{post.entry.title}</h2>
<p>{post.entry.excerpt}</p>
</article>
))}
</div>
);
}
// app/blog/[slug]/page.tsx
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await reader.collections.posts.read(params.slug);
if (!post || post.status !== "published") {
notFound();
}
// Render MDX content
const { content } = await post.content();
return (
<article>
<h1>{post.title}</h1>
<DocumentRenderer document={content} />
</article>
);
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await reader.collections.posts.all();
return posts
.filter((p) => p.entry.status === "published")
.map((p) => ({ slug: p.slug }));
}
GitHub Mode (Team Editing)
// keystatic.config.ts — GitHub mode
export default config({
storage: {
kind: "github",
repo: { owner: "your-org", name: "your-repo" },
branchPrefix: "keystatic/",
},
// Team members authenticate via GitHub OAuth
// Content changes create pull requests
// ...rest of config
});
Outstatic: GitHub-Native Simplicity
Outstatic is the simplest path to a CMS — authenticate with GitHub, create collections, and content commits go directly to your repository via GitHub API.
Installation
npm install outstatic
Next.js Integration
// app/api/outstatic/[...ost]/route.ts
import { Outstatic } from "outstatic";
const handler = Outstatic();
export { handler as GET, handler as POST };
// app/outstatic/[[...ost]]/page.tsx
import { OstClient } from "outstatic/client";
export default function Page(props: { params: { ost: string[] } }) {
return <OstClient ostPath={props.params.ost} />;
}
Environment Variables
# .env.local
GITHUB_ID=your_github_app_id
GITHUB_SECRET=your_github_app_secret
OST_TOKEN_SECRET=random_secret_for_jwt
Reading Content
// app/blog/page.tsx
import { getCollections, getDocuments } from "outstatic/server";
export default async function BlogPage() {
// Documents stored as MDX files in outstatic/content/posts/*.md
const posts = await getDocuments("posts", [
"title",
"slug",
"description",
"coverImage",
"publishedAt",
"author",
"status",
]);
const published = posts.filter((post) => post.status === "published");
return (
<div>
{published.map((post) => (
<article key={post.slug}>
<h2>{post.title}</h2>
<p>{post.description}</p>
</article>
))}
</div>
);
}
// app/blog/[slug]/page.tsx
import { getDocumentSlugs, load } from "outstatic/server";
import { MDXRemote } from "next-mdx-remote/rsc";
export default async function PostPage({ params }: { params: { slug: string } }) {
const db = await load();
const post = await db
.find({ collection: "posts", slug: params.slug }, ["title", "content", "publishedAt"])
.first();
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<MDXRemote source={post.content} />
</article>
);
}
export async function generateStaticParams() {
const slugs = await getDocumentSlugs("posts");
return slugs.map((slug) => ({ slug }));
}
Outstatic Admin UI
Outstatic's admin UI at /outstatic provides:
- Field types: text, rich text, tags, date, media
- GitHub-backed authentication (no separate user system)
- Automatic slugs from titles
- Media uploaded to your GitHub repo or external provider
- Draft/published status
- No database, no infrastructure
Feature Comparison
| Feature | Payload CMS v3 | Keystatic | Outstatic |
|---|---|---|---|
| Storage | Postgres / MongoDB | Git files | Git (GitHub API) |
| Admin UI | /admin | /keystatic | /outstatic |
| Auth | Own user system | GitHub OAuth | GitHub OAuth |
| Access control | Field-level | Role-level (GitHub) | GitHub permissions |
| Drafts | ✅ Native | Git branches | Status field |
| Relationships | ✅ Database joins | References | No |
| Rich text | Lexical editor | Markdoc / prose | Tiptap |
| Media management | ✅ Built-in | Local files | GitHub / S3 |
| REST API | ✅ Auto-generated | No API | No API |
| GraphQL | ✅ Auto-generated | No | No |
| Real-time collab | No | No | No |
| Infrastructure | DB required | None | None |
| Offline editing | DB required | ✅ Local mode | No |
| GitHub stars | 22k | 2.5k | 4k |
| npm weekly | 40k | 15k | 10k |
| License | MIT | MIT | MIT |
| Hosting | Anywhere with DB | Vercel/edge | Vercel/edge |
When to Use Each
Choose Payload CMS v3 if:
- Need a full content platform — auth, access control, relationships, workflows
- Building a multi-user CMS where editors log in with email/password
- Content model has complex relationships (posts → categories → authors → media)
- Need a REST or GraphQL API for a mobile app or external consumers
- E-commerce, membership sites, or multi-tenant applications
- Draft/publish workflows with scheduled publishing
Choose Keystatic if:
- Developer blog, documentation site, or content that's code-adjacent
- Prefer content versioned in git alongside your code
- Want an excellent local editing experience in development
- Small team who can work through GitHub branches for review
- MDX content with custom components
- Zero infrastructure overhead is a priority
Choose Outstatic if:
- Simplest possible setup — minutes to working CMS
- Non-technical editors who are comfortable with GitHub
- Content is simple (title, rich text, date, image, tags)
- Want to avoid all database and auth infrastructure
- Personal blog or small marketing site where GitHub auth is fine
- Proof of concept or prototype before committing to a full CMS
Methodology
Data sourced from Payload CMS v3 documentation (payloadcms.com/docs), Keystatic documentation (keystatic.com/docs), Outstatic documentation (outstatic.com/docs), GitHub star counts as of February 2026, npm weekly download statistics as of February 2026, and community discussions from the Next.js Discord, the Payload CMS Discord, and developer blog comparisons.
Related: Strapi vs Directus vs Sanity for broader headless CMS options, or MDX vs Contentlayer vs Velite for git-based content processing pipelines.