Skip to main content

Payload CMS v3 vs Keystatic vs Outstatic: Headless CMS 2026

·PkgPulse Team

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

FeaturePayload CMS v3KeystaticOutstatic
StoragePostgres / MongoDBGit filesGit (GitHub API)
Admin UI/admin/keystatic/outstatic
AuthOwn user systemGitHub OAuthGitHub OAuth
Access controlField-levelRole-level (GitHub)GitHub permissions
Drafts✅ NativeGit branchesStatus field
Relationships✅ Database joinsReferencesNo
Rich textLexical editorMarkdoc / proseTiptap
Media management✅ Built-inLocal filesGitHub / S3
REST API✅ Auto-generatedNo APINo API
GraphQL✅ Auto-generatedNoNo
Real-time collabNoNoNo
InfrastructureDB requiredNoneNone
Offline editingDB required✅ Local modeNo
GitHub stars22k2.5k4k
npm weekly40k15k10k
LicenseMITMITMIT
HostingAnywhere with DBVercel/edgeVercel/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.

Comments

Stay Updated

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