Skip to main content

Payload CMS vs Strapi vs Directus: Headless CMS for Node.js (2026)

·PkgPulse Team

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

FeaturePayload CMSStrapiDirectus
Schema definitionCode (TypeScript)Visual builder + JSONDatabase-first
TypeScript✅ (first-class)✅ (v5)✅ (SDK)
Admin panel✅ (built-in)✅ (built-in)✅ (Data Studio)
REST API
GraphQL✅ (plugin)✅ (plugin)
DatabaseMongoDB, PostgresSQLite, Postgres, MySQLAny SQL
Access control✅ (field-level)✅ (role-based)✅ (granular)
Media handling
Webhooks✅ (Flows)
Real-time✅ (WebSocket)
Local API✅ (typed)✅ (SDK)
Self-hosted
Cloud offeringPayload CloudStrapi CloudDirectus Cloud
Next.js integration✅ (native)Via APIVia API
Plugin ecosystemGrowing✅ (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.

Compare CMS and backend libraries on PkgPulse →

Comments

Stay Updated

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