Skip to main content

Guide

Payload CMS vs Strapi vs Directus 2026

Compare Payload CMS, Strapi, and Directus for headless content management in Node.js. TypeScript-first CMS, REST and GraphQL APIs, admin panels, and which.

·PkgPulse Team·
0

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

TypeScript Developer Experience in Depth

Payload CMS's TypeScript story is the strongest of the three. When you run payload generate:types, it reads your collection and global configurations and outputs a payload-types.ts file containing TypeScript interfaces for every collection, field, and relationship in your CMS. This means calling payload.find({ collection: "posts" }) returns PaginatedDocs<Post> where Post is the generated type — no casting required. As your content model evolves, regenerating types catches breaking changes at compile time rather than at runtime. Strapi v5 added TypeScript support throughout, but the schema-as-JSON approach means types are generated from the JSON schema files rather than from TypeScript source — the round-trip introduces a layer of indirection. Directus's TypeScript SDK supports generic typing via the MySchema interface pattern, which provides type safety for known collections but requires manually keeping the interface synchronized with the database schema.

Performance and Caching Architecture

Payload's local API is the key performance differentiator for Next.js applications. When Payload and Next.js run in the same process, payload.find() calls go directly to the database without HTTP overhead — no serialization, no network, no parsing. This eliminates an entire network round-trip for every data fetch, which matters significantly for pages that fetch multiple collections. The tradeoff is that Payload must be co-located with the Next.js server, ruling out edge deployments where the database connection would add unacceptable latency. Strapi's REST API adds HTTP overhead but enables a clean separation between the CMS process and the frontend server — beneficial when multiple frontends consume the same CMS. Directus's SDK communicates over HTTP even when used server-side, though the Directus Cloud offering provides edge-optimized delivery.

Plugin Ecosystem and Extensibility

Strapi's plugin ecosystem is the most mature, with over 200 published plugins covering analytics, search, email providers, storage, authentication, and more. The plugin API is stable and well-documented, making it straightforward to add new functionality without modifying the core. Directus's extension system supports custom endpoints (Express routes added to the Directus server), custom hooks (lifecycle event handlers), and custom interfaces (Vue components for the admin dashboard) — the Vue component requirement for custom interfaces is a constraint for teams primarily working in React. Payload's plugin ecosystem is growing rapidly following its v3 release — the community has built plugins for Stripe integration, search, SEO utilities, and content import/export, but Strapi's ecosystem remains larger.

Deployment and Infrastructure Considerations

Directus's database-first approach means you can run it against an existing production database with live data — connect it to your PostgreSQL database and immediately get an admin interface for your existing tables. This is uniquely valuable for adding content editing capabilities to a legacy application without migrating to a new data model. Payload and Strapi both own the database schema, making them less suitable for wrapping existing databases. For deployment, Strapi requires Node.js 18+ and is RAM-intensive due to its admin panel build process — allocate at least 1 GB RAM for the Strapi process in production. Payload's Next.js integration reduces operational complexity by running in the same Next.js process — one process, one deployment, one set of environment variables to manage.

Security and Access Control

Payload's access control system operates at the field level — individual fields within a collection can be hidden, read-only, or fully restricted based on the requesting user's role. This granularity is important for multi-tenant applications where different users see different fields of the same record. Strapi's role-based access control operates at the content-type and action level (create, read, update, delete per collection) with field-level filtering available via middleware. Directus's access control is the most granular — permissions can restrict access to individual fields, filter results by complex conditions, and limit which fields are visible in the admin interface per role. For applications where different user roles have meaningfully different views of the same data, Directus's permission model is the most expressive.

Internationalization and Multi-Language Support

Multi-language content management is a common enterprise requirement that each CMS addresses at the schema level. Payload CMS's localization system requires enabling localization per field in the collection config — set localized: true on any field to store separate values per locale. The locale query parameter controls which locale is returned in API responses. Strapi's internationalization plugin adds a locale field to content types and maintains separate locale entries linked by their shared content ID, making it possible to list all translations of an entry through the relations API. Directus's multi-language approach uses a translations relation table pattern — a posts_translations collection links each post to locale-specific field values, maintaining referential integrity through the database's foreign key constraints. For teams managing content in more than two languages simultaneously, Directus's database-level translation model offers the most straightforward SQL query patterns for fetching all language variants of a record in a single join query.

Content Versioning and Draft Management

Draft and publish workflows are a core requirement for editorial teams, and each CMS handles the concept differently. Payload CMS's versioning system tracks every save as a version, enabling rollback to any previous state — enable it per collection by setting versions: { drafts: true } in the collection config, and Payload creates a _posts_versions shadow collection storing complete snapshots. This makes audit logging straightforward: query the versions collection to see who changed what and when. Strapi's draft and publish workflow is simpler — content types have a publishedAt field, and unpublished records are held in draft state until a content manager explicitly publishes them. Strapi v5 supports scheduled publishing via the Scheduled Publishing plugin. Directus's content versioning is handled through its Revisions feature — every create and update operation creates a revision record linked to the original item, enabling diff views and rollback through the Data Studio interface. For teams with multiple editors, Directus's granular access control combined with revision tracking creates the strongest audit trail.

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 →

Compare Payload and Strapi package health on PkgPulse.

See also: AVA vs Jest and Contentful vs Sanity vs Hygraph, amqplib vs KafkaJS vs Redis Streams.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.