Payload CMS vs Strapi vs Directus: Headless CMS for Node.js (2026)
TL;DR
Payload CMS is the TypeScript-first headless CMS — code-defined schemas, Next.js integration, access control, built-in admin panel, fully type-safe. Strapi is the most popular open-source headless CMS — visual content-type builder, plugin ecosystem, REST and GraphQL, content manager dashboard. Directus is the database-first headless CMS — wraps any SQL database with an instant API, visual data studio, no migration lock-in. In 2026: Payload for TypeScript developers who want code-first CMS, Strapi for the largest ecosystem and visual content modeling, Directus for wrapping existing databases with an admin panel.
Key Takeaways
- Payload: ~50K weekly downloads — TypeScript-first, code-defined, Next.js, access control
- Strapi: ~100K weekly downloads — visual builder, plugins, most popular, REST/GraphQL
- Directus: ~30K weekly downloads — database-first, any SQL, instant API, no lock-in
- Payload has the best TypeScript developer experience (code-defined schemas)
- Strapi has the largest plugin ecosystem and community
- Directus doesn't own your data — wraps your existing database
Payload CMS
Payload — TypeScript-first CMS:
Collection config
// collections/Posts.ts
import { CollectionConfig } from "payload"
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "publishedAt"],
},
access: {
read: () => true, // Public read
create: ({ req }) => req.user?.role === "admin",
update: ({ req }) => req.user?.role === "admin",
delete: ({ req }) => req.user?.role === "admin",
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
unique: true,
admin: { position: "sidebar" },
},
{
name: "content",
type: "richText",
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
},
{
name: "author",
type: "relationship",
relationTo: "users",
},
{
name: "tags",
type: "array",
fields: [
{ name: "tag", type: "text" },
],
},
{
name: "status",
type: "select",
defaultValue: "draft",
options: ["draft", "published", "archived"],
admin: { position: "sidebar" },
},
{
name: "publishedAt",
type: "date",
admin: { position: "sidebar" },
},
],
}
Payload config
// payload.config.ts
import { buildConfig } from "payload"
import { mongooseAdapter } from "@payloadcms/db-mongodb"
import { slateEditor } from "@payloadcms/richtext-slate"
import { Posts } from "./collections/Posts"
import { Users } from "./collections/Users"
import { Media } from "./collections/Media"
export default buildConfig({
collections: [Posts, Users, Media],
editor: slateEditor({}),
db: mongooseAdapter({
url: process.env.DATABASE_URI!,
}),
admin: {
user: Users.slug,
},
typescript: {
outputFile: "./types/payload-types.ts",
},
})
Next.js integration
// app/posts/[slug]/page.tsx
import { getPayload } from "payload"
import config from "@payload-config"
export default async function PostPage({
params,
}: {
params: { slug: string }
}) {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: "posts",
where: {
slug: { equals: params.slug },
status: { equals: "published" },
},
})
const post = docs[0]
if (!post) return <div>Not found</div>
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div>{/* Render rich text content */}</div>
</article>
)
}
// Generate static params:
export async function generateStaticParams() {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: "posts",
where: { status: { equals: "published" } },
limit: 1000,
})
return docs.map((post) => ({ slug: post.slug }))
}
Local API (type-safe)
import { getPayload } from "payload"
import config from "@payload-config"
const payload = await getPayload({ config })
// Create — fully typed:
const post = await payload.create({
collection: "posts",
data: {
title: "New Post",
slug: "new-post",
status: "draft",
content: [/* rich text */],
},
})
// Find with filters:
const { docs, totalDocs, totalPages } = await payload.find({
collection: "posts",
where: {
status: { equals: "published" },
tags: { contains: "react" },
},
sort: "-publishedAt",
limit: 10,
page: 1,
})
// Update:
await payload.update({
collection: "posts",
id: post.id,
data: { status: "published", publishedAt: new Date().toISOString() },
})
// Delete:
await payload.delete({ collection: "posts", id: post.id })
Strapi
Strapi — open-source headless CMS:
Content-type schema
// src/api/post/content-types/post/schema.json
{
"kind": "collectionType",
"collectionName": "posts",
"info": {
"singularName": "post",
"pluralName": "posts",
"displayName": "Post"
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "title"
},
"content": {
"type": "richtext"
},
"featuredImage": {
"type": "media",
"allowedTypes": ["images"]
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"tags": {
"type": "json"
},
"status": {
"type": "enumeration",
"enum": ["draft", "published", "archived"],
"default": "draft"
},
"publishedAt": {
"type": "datetime"
}
}
}
REST API
// Fetch posts (REST):
const response = await fetch("http://localhost:1337/api/posts?" + new URLSearchParams({
"filters[status][$eq]": "published",
"sort": "publishedAt:desc",
"pagination[page]": "1",
"pagination[pageSize]": "10",
"populate": "featuredImage,author",
}))
const { data, meta } = await response.json()
// data: [{ id, attributes: { title, slug, content, ... } }]
// meta: { pagination: { page, pageSize, pageCount, total } }
// Create post:
await fetch("http://localhost:1337/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
data: {
title: "New Post",
slug: "new-post",
status: "draft",
},
}),
})
Custom controller
// src/api/post/controllers/post.ts
import { factories } from "@strapi/strapi"
export default factories.createCoreController(
"api::post.post",
({ strapi }) => ({
// Custom find with business logic:
async find(ctx) {
const { query } = ctx
const entity = await strapi.service("api::post.post").find({
...query,
filters: {
...query.filters,
status: "published",
},
})
return this.transformResponse(entity)
},
// Custom action:
async findBySlug(ctx) {
const { slug } = ctx.params
const entity = await strapi.db.query("api::post.post").findOne({
where: { slug },
populate: ["featuredImage", "author"],
})
if (!entity) return ctx.notFound()
return this.transformResponse(entity)
},
})
)
Middleware and lifecycle hooks
// src/api/post/content-types/post/lifecycles.ts
export default {
async beforeCreate(event) {
const { data } = event.params
// Auto-generate slug:
if (data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
}
},
async afterCreate(event) {
const { result } = event
// Notify on publish:
if (result.status === "published") {
await strapi.service("api::notification.notification").send({
type: "new-post",
postId: result.id,
})
}
},
}
Directus
Directus — database-first CMS:
REST API
import { createDirectus, rest, readItems, createItem, updateItem } from "@directus/sdk"
// Initialize client:
const client = createDirectus("http://localhost:8055")
.with(rest())
// Read posts:
const posts = await client.request(
readItems("posts", {
filter: {
status: { _eq: "published" },
},
sort: ["-date_published"],
limit: 10,
fields: ["id", "title", "slug", "content", "featured_image.*", "author.*"],
})
)
// Create post:
const newPost = await client.request(
createItem("posts", {
title: "New Post",
slug: "new-post",
status: "draft",
content: "<p>Hello world</p>",
})
)
// Update post:
await client.request(
updateItem("posts", newPost.id, {
status: "published",
date_published: new Date().toISOString(),
})
)
Authentication
import {
createDirectus, rest, authentication,
readItems, readMe,
} from "@directus/sdk"
// With authentication:
const client = createDirectus("http://localhost:8055")
.with(authentication("json"))
.with(rest())
// Login:
await client.login("admin@example.com", "password")
// Authenticated requests:
const me = await client.request(readMe())
console.log("Logged in as:", me.email)
// Static token:
const publicClient = createDirectus("http://localhost:8055")
.with(rest())
.with(authentication("json"))
// Use static token:
const staticClient = createDirectus("http://localhost:8055")
.with(rest({
onRequest: (options) => ({
...options,
headers: {
...options.headers,
Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
},
}),
}))
Real-time subscriptions
import { createDirectus, realtime } from "@directus/sdk"
const client = createDirectus("http://localhost:8055")
.with(realtime())
await client.connect()
// Subscribe to changes:
const { subscription } = await client.subscribe("posts", {
event: "create",
query: {
fields: ["id", "title", "status"],
filter: { status: { _eq: "published" } },
},
})
for await (const event of subscription) {
console.log("New post:", event)
}
TypeScript SDK types
import { createDirectus, rest, readItems } from "@directus/sdk"
// Define your schema:
interface MySchema {
posts: Post[]
authors: Author[]
categories: Category[]
}
interface Post {
id: number
title: string
slug: string
content: string
status: "draft" | "published" | "archived"
author: Author | number
category: Category | number
date_published: string
}
interface Author {
id: number
name: string
email: string
}
interface Category {
id: number
name: string
slug: string
}
// Typed client:
const client = createDirectus<MySchema>("http://localhost:8055")
.with(rest())
// Fully typed response:
const posts = await client.request(
readItems("posts", {
fields: ["id", "title", "slug", "author.name"],
filter: { status: { _eq: "published" } },
})
)
// posts is typed as Post[]
Feature Comparison
| Feature | Payload CMS | Strapi | Directus |
|---|---|---|---|
| Schema definition | Code (TypeScript) | Visual builder + JSON | Database-first |
| TypeScript | ✅ (first-class) | ✅ (v5) | ✅ (SDK) |
| Admin panel | ✅ (built-in) | ✅ (built-in) | ✅ (Data Studio) |
| REST API | ✅ | ✅ | ✅ |
| GraphQL | ✅ (plugin) | ✅ (plugin) | ✅ |
| Database | MongoDB, Postgres | SQLite, Postgres, MySQL | Any SQL |
| Access control | ✅ (field-level) | ✅ (role-based) | ✅ (granular) |
| Media handling | ✅ | ✅ | ✅ |
| Webhooks | ✅ | ✅ | ✅ (Flows) |
| Real-time | ❌ | ❌ | ✅ (WebSocket) |
| Local API | ✅ (typed) | ✅ | ✅ (SDK) |
| Self-hosted | ✅ | ✅ | ✅ |
| Cloud offering | Payload Cloud | Strapi Cloud | Directus Cloud |
| Next.js integration | ✅ (native) | Via API | Via API |
| Plugin ecosystem | Growing | ✅ (largest) | ✅ (Extensions) |
| Weekly downloads | ~50K | ~100K | ~30K |
When to Use Each
Use Payload CMS if:
- Want TypeScript-first CMS with code-defined schemas
- Building with Next.js (native integration)
- Need field-level access control
- Prefer configuration-as-code over visual builders
Use Strapi if:
- Want the most popular open-source headless CMS
- Prefer visual content-type building (no-code schema design)
- Need a large plugin ecosystem
- Building content-heavy websites with non-technical editors
Use Directus if:
- Want to wrap an existing database with an instant API
- Need database portability (no vendor lock-in)
- Want real-time subscriptions out of the box
- Need to manage data that already exists in SQL tables
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on Payload v3.x, Strapi v5.x, and Directus v11.x.