A Look at Payload CMS

July 14, 2023 23 min read

What a newfangled TypeScript CMS looks like to a Craft CMS fan.

I’ve been curious what years of Craft CMS client work might look like if I was entering the job market with fresh eyes.

These are my notes from looking around and spending time with Payload CMS, not a guide or complete walkthrough!

When a CMS Matters

Craft offers a solid toolkit for building a variety of sites while keeping life pleasant for developers and content editors. It resembles a conventional CMS immediately after you’ve installed it, but it’s a system for building custom websites and not a blogging tool you stretch and theme the bejesus out of until it appears to be a CMS. That’s why I happily built client sites with Craft CMS for such a long time.

I have so much fun building with Astro and Eleventy I want to think a CMS isn’t necessary. But I never built a site just for developers. In a lot of cases, I took a lovely site made by developers and brought it into a CMS so more people could edit and publish content directly and let those developers focus on on other things. Tools like Markdown and Git aren’t best for everyone, no matter how hard I try to believe it. I can’t blame them—most people are used to doing things in a browser, so of course that’s how they should manage their website.

Flat content storage also starts to get cumbersome as the organization and its content grows. Projects like Tina and Keystatic can offer a GUI on top of flat files to bridge the gap, but at some point a relational database and broader infrastructure offer simpler, proven ways of solving certain problems.

Craft hit a sweet spot as a monolith, and it’s been a capable platform for a variety of project types and sizes.

But the cost of focusing on that platform has come with limited awareness of what else is out there. I can’t pick the best tool when I only know one, so time to look around!

Search Criteria

If I was just starting out, looking to do client work myself or with an agency, would I be looking at PHP content management systems? Probably not. So if not Craft CMS, what?

Whatever the answer, I’d like to be able to…

  1. Get it running quickly and start modeling content.
  2. Arrange and rearrange fields in a way that’s detailed, flexible, and not overly constrained.
  3. Establish groundwork with solid core features that aren’t relegated to plugins: flexible content types, file management, localization, content versioning, user auth+management, and customizable permissions.
  4. Enjoy working with a modern UI.
  5. Successfully stumble through my first challenges without getting lost.

I’m willing to forego the monolith and look at headless content management systems at this point because they’re everywhere, and I think separation of concerns can be a good thing. Anybody that needs a one-stop shop for publishing with minimal setup should probably be looking at hosted services that’ll take little effort to start using immediately, no effort to maintain, and an easy way to get a hands-on idea of what they’d like to get out of a more complex, custom project.

What I Looked At

I pawed at Statamic and Kirby again before moving away from PHP and spending time with Keystone and Payload CMS.

I’m only JavaScript-adjacent, so Keystone and Payload aren’t exactly in my wheelhouse—but all the better to approach like a beginner. No need to wear a disguise, because I genuinely don’t know Node.js as well as I do PHP.

This Keystone overview video was light on the CMS-in-action shots I hoped for, but it gave me the sense of a framework meant to be solid footing for any project that would continue to be supportive over time. Except for real.

I don’t know Keystone well enough to say, but its integral relationship with Prisma feels critical and keeps nagging at me. I’ve always treated content like something sacred, and felt like carefully-modeled content is the only kind worth having. It involves articulating goals, taking inventory, building a structure meant to mature gracefully, and using things like field instructions and validation to help encourage steady growth that’s not a hairball enveloping children and dogs being chased by fire trucks. Having an ORM seems like it would bring a positive, stabilizing force that could help a project evolve nicely.

My hesitation with Keystone is that it seems like it’s geared for bigger projects and teams. It doesn’t resemble much at all once you’ve got it running, let alone a CMS. I didn’t find a bells-and-whistles demo, and the local CMS example I spun up felt full of potential without offering much to see. If I wanted to approximate a Craft project, it’d take a lot more work to set up and explore.

It may well be too broad a platform to pretend at only being a CMS, but I’m looking for a CMS and don’t have infinite time and imagination to connect the dots.

If I missed an obvious video or demo, or if you want to pay me to spend weeks or months getting Keystone into a roughly Craft-equivalent form, please give me a shout!

Enter Payload

Once I discovered Payload CMS I had fun playing with it for a few weeks.

I started with their demo, which got my curiosity going.

In minutes, I had it running locally with a handful of content collections and custom fields. It was fast, even with the free (remote!) MongoDB Atlas cluster I set up for testing.

Screenshot of the Payload CMS browser UI, with a sidebar and main content area listing Collections and Globals.
The Payload dashboard with some of the collections I’m working on.

The first-party remote storage plugin was quick to set up and happy with the Cloudflare R2 buckets I’ve been using lately.

It easily survived the first thirty minutes of prodding, so I kept working with the goal of taking this flat, Markdown+Markdoc-powered Astro site and pulling the content into a CMS like I might for a client with Craft.

These are the challenges I’ve been through so far.

Orienting

It was refreshing to be greeted by a control panel with dark mode, a tidy and sparing UI, and subtle, intuitive details.

Screenshot of the Payload CMS browser UI, with a set of fields for a post titled “Test Thought”.
A Payload “doc,” analogous to a Craft CMS entry.

One of those little things is the “Edit” link built into relational fields, which opens the relevant doc in a slideout for editing.

Screenshot of a “Tags” field, with emphasis on the edit icon next to the “development” tag that’s been added.

Another thing I liked was the “Add Block” modal, which works like the Matrix Field Preview plugin. (You can customize Payload’s block representation thumbnails, I just haven’t here!)

Screenshot of the “Add Block” modal that slides over post fields after clicking a block field’s “Add Block” button; the modal lists a name and thumbnail for each available block type and has a search field at the top.

While it’s cumbersome with a small number of block types, it’s a much better way of handling a whole bunch of block types and helping the editor visualize them with more than a name. Craft has the more optimal UX for a small number of block types, but I like this approach for anything beyond two handfuls. Search is also handy for a wilderness of block types.

It’s nice to build a content model in VS Code (with autocomplete!) instead of a GUI.

With Craft, you click through the control panel to set up content collections and their fields. Craft’s underlying project config feature writes them to YAML that can be versioned and synced across environments.

Payload isn’t the only CMS that lets you define the content layout with code, but if you’re primarily a developer it makes a lot more sense. Autocomplete is helpful, setup is fast, and I could see it paying dividends being able to re-use common field layouts across projects over time.

Payload makes fewer default assumptions than Craft, but the ability to make quick changes makes some of the differences trivial.

I needed to set a default entry date and author, for example. Payload’s fields are each empty unless you specify a default value. Not only was this straightforward to set, but adding field conditions or multiple authors or even style customizations would be quick and simple, without any need for a custom plugin or module.

Content is broadly organized into collections, with individual items being “docs” in Payload parlance. There’s a concept of globals just like Craft’s. A dull, barebones collection with only a title would be defined like this:

src/collections/Posts.ts
import { CollectionConfig } from "payload/types"

const Posts: CollectionConfig = {
  slug: "posts",
  fields: [
    {
      name: "title",
      type: "text",
      required: true,
    },
  ],
}

export default Posts

You can create as many of these you want. You tell Payload to use them in its config:

src/payload.config.ts
import { buildConfig } from "payload/config"
import Posts from "./collections/Posts"

export default buildConfig({
  serverURL: process.env.SERVER_URL,
  collections: [Posts],
})

This config file is where you’d also define globals, add plugins, and configure things like users and GraphQL. Because this is all TypeScript, autocomplete everywhere makes it easier to get acquainted and quickly spot blunders.

Screenshot of TypeScript autocomplete in Zed, where the default config’s `admin` object offers `dateFormat` and `disable` options for the letter “d” at the cursor
TypeScript means autocomplete everywhere. No extra configuration in Zed (above) or VS Code.

There are a bunch of available fields, and I like that the docs offer example use cases for each one.

Setting a Default Entry Date and Author

Customizing those fields felt intuitive.

Adding a date to the field layout looks like this:

{
  name: "publishedDate",
  type: "date",
}

Having to always set the publish date is a minor annoyance, but it couldn’t be much simpler to default to the current date:

{
  name: "publishedDate",
  type: "date",
  defaultValue: () => {
    return Date();
  }
}

I wanted to have an author field that, like Craft CMS, would default to the current user. So I started by adding an author relationship field:

{
  name: "author",
  type: "relationship",
  required: true,
  relationTo: "users",
}

The docs for default values point out that a user object is one of the optional arguments for the defaultValue function, and like Craft the field wants an ID for the related thing. We don’t have to make a big deal about it, but this worked on my first try:

{
  name: "author",
  type: "relationship",
  required: true,
  relationTo: "users",
  defaultValue: ({ user }) => {
    return user.id;
  }
}

Succinct and intuitive!

It feels good to build the field layout with code and see changes reflected in the browser, rather than edit the field layout in the browser and either navigate to a new entry or keep a separate tab open.

Payload comes with fewer assumptions than Craft, which has been a recurring theme. That’s good for simplicity in cases like this, and I’m sure it’ll prove to be tricky later on because Craft CMS is also a more complex system.

So on one hand I have to create an author field on each doc—the equivalent of a Craft Element Type—but some things would be much faster to pull off.

Multiple authors, for example, would be a matter of specifying hasMany: true:

{
  name: "author",
  type: "relationship",
  required: true,
  hasMany: true,
  relationTo: "users",
  defaultValue: ({ user }) => {
    return user.id;
  }
}

There are Craft equivalents for field display like conditional logic and using admin properties to set things like width and custom CSS classes.

More exciting than that is the ability to easily swap out the React component used rendering the field! No plugin or module necessary. If field hooks and conditions aren’t enough, you can bring your own component or extend Payload’s and take advantage of deeper React hooks to most likely get away with whatever you want.

Enabling Autosave and Versioning

We looked at this basic collection—which is something like an entry type—earlier:

src/collections/Posts.ts
import { CollectionConfig } from "payload/types"

const Posts: CollectionConfig = {
  slug: "posts",
  fields: [
    {
      name: "title",
      type: "text",
      required: true,
    },
  ],
}

export default Posts

Payload has support for drafts and autosave, and thoroughly covers configuration options and underlying APIs.

I got exactly the behavior I wanted—autosave with explicit publishing—after a quick update to my collection definition:

src/collections/Posts.ts
import { CollectionConfig } from "payload/types"

const Posts: CollectionConfig = {
  slug: "posts",
  versions: {
    drafts: {
      autosave: true,
    },
  },
  fields: [
    {
      name: "title",
      type: "text",
      required: true,
    },
  ],
}

export default Posts

I was gleeful to discover that the UI for browsing revisions comes with diffs!

Screenshot comparing version data in the browser, with everything represented as JSON and changes identified with red and green highlighting.
Content diffs! It does content diffs!

I’m a rare bird that uses Craft’s revision notes field religiously because there’s otherwise no way for another editor to quickly understand what changed in a revision. I wrote an ancient Craft 2 plugin for a client that visualized content diffs for that project, but never figured out how to make it a public plugin with broad field type support. But here are content diffs in Payload, effortlessly and gloriously!

Craft’s field types can be a lot more complicated, and this is another one of those cases where Payload’s simplicity makes some neat things possible.

Changing the Code Field’s Language

Some of the shine wore off when I fell down a hole on this next one.

I started setting up modular content blocks like you would with Matrix, one of them being a “Code” block with a code field and a language select menu.

My custom “code” block, which includes a language select field, a filename input field, and the code field I’m writing about

Naturally, I wanted the language selection to be used to set syntax highlighting for the code field. (Also useful via GraphQL where the eventual front end would want to know how to treat a code snippet.)

I figured my previous little customizations were easy, so this would be too.

Nope!

I failed to get the fields talking to each other with hooks, got close but failed to store data with a custom component, and then facepalmed asking about my approach in Discord and then discovering someone had already asked about and solved the same problem.

I won’t repeat the answer here, but it involved overriding the code field’s component, getting the language field’s value with a useFormFields hook, and creating the code field using that language. It was frustrating to get so close, but I learned a bit about hooks and components and where I should start looking for answers.

Everybody’s caught not reading the docs at some point. Nice work, docs!

Migrating Content

A big part of any enduring web project is getting data into and out of it.

I have no real-world experience with MongoDB, so I only expect it should be fast and distributable and that I’ll need more data to see where it strains with lots of content and relationships.1

I know that with Craft, I can use Feed Me and content migrations to make data intake extensive and repeatable.

My simple case here was to take the collections and Markdown/Mardoc behind this Astro site and pull it into a Payload layout.

But how?

There are several ways you might go about it, like using GraphQL mutations or the REST API, but I chose to write some Node.js scripts and use Payload’s local API.

I fumbled around with promises a bit, because that’s just the level I’m operating at right now, but I got content flowing in faster than I expected.

My most complex import script weighs in at 120 lines right now, but the approach is fairly simple:

  1. Initialize a Payload instance with local: true, and use its onInit to run a custom import method and exit.
  2. Read a folder of Markdown files.
  3. Loop through and parse the files along with their frontmatter.
  4. Build a new data object representing a Payload doc.
  5. Call payload.create(), adding the data to a collection as a new doc.

It looks something like this:

src/import/posts-markdown.ts
// ...
payload.init({
  secret: process.env.PAYLOAD_SECRET,
  mongoURL: process.env.MONGODB_URI,
  local: true,
  onInit: async () => {
    tryImport().then(() => {
      process.exit(0)
    })
  },
})

const tryImport = async (): Promise<void> => {
  const filenames = await fs.promises.readdir(path.resolve(markdownDir))

  for (let file of filenames) {
    const absolutePath = path.join(markdownDir, file)
    const fileData = await fs.promises.readFile(absolutePath)
    const parsed = frontmatter(fileData.toString())

    const data = {
      title: parsed.data.title,
      slug: slugify(parsed.data.title),
      _status:
        parsed.data.hasOwnProperty("draft") && parsed.data.draft === true
          ? "draft"
          : "published",
      author: "649b79e5733cb2bb5e6d4cfe", // cheating! (Payload author ID)
      publishedDate: parsed.data.pubDate,
      createdAt: parsed.data.pubDate,
    }

    await payload.create({
      collection: "posts",
      overrideAccess: true,
      data,
    })
  }
}

Above, I’m cheating by passing the Payload author ID directly, I’ve left out the parts that prepare content for Slate rich text blocks, and I omitted some more async functions that find or create related tag docs on the fly. (Here’s a more complete example.)

It’s not much to manage, and I can do whatever I want prepping the data for Payload. I can run the import from the command line:

npx ts-node -T src/import/posts-markdown.ts

I’m comfortable with this as an import process, and the only open question is how to migrate live data not just with newly-added fields but changes to the content structure.

It looks like the Payload team intends to make big moves with an ORM and support for multiple databases, which will include built-in migration features. In the meantime, they’ve been pointing at tools like migrate-mongo and node-db-migrate, and offered an example.

Slate

Payload ships with a rich text field that’s powered by Slate, a project with a website that utterly betrays how cool it is. It calls itself a framework for building rich text editors, and it’s one of several projects that’ll show you a WYSIWYG text editor but give you something broader and more powerful than the thing in your browser.

ASTs are not new, but digging into Payload got me up close to Slate for the first time and it blew my little mind.

If you know what an AST is you might want to skip ahead, because this is going to sound like a modern person discovering plumbing.

But Slate is to rich text as Matrix was to longform content so many years ago.

Gone are the pains of storing HTML from a WYSIWYG field, because the content is deserialized into JSON and serialized back into HTML for the editor. Once the data is stored in a predictable, consistent JSON structure, you can turn it into whatever you want wherever you want.

If you’re like me, a visual example is necessary here.

You’ve probably used Redactor or CKEditor:

Screenshot of a Redactor field in Craft CMS, where the editor reads “I’m a little sentence with a link in it.” and “a link” is an anchor

It stores this in the database:

<p>
  I’m a little sentence with
  <a href="https://hamsterdance.neocities.org">a link</a> in it.
</p>

The Slate editor doesn’t look or behave wildly different:

Screenshot of a Slate field in Payload, where the editor reads “I’m a little sentence with a link in it.” and “a link” is an anchor

But here’s what it stores in the database:

[
  {
    "children": [
      {
        "text": "I’m a little sentence with "
      },
      {
        "children": [
          {
            "text": "a link"
          }
        ],
        "linkType": "custom",
        "type": "link",
        "url": "https://hamsterdance.neocities.org"
      },
      {
        "text": " in it."
      }
    ]
  }
]

If you’re wondering whether you can customize the editor to do what you want, spend a moment with the Plate demo. It’s a project built on top of Slate, and it’ll quickly demonstrate the answer is “omg yes you can customize it.”

While the included rich text field does not include every conceivable feature, the Payload team once again took care with details like adding a link—so maintaining relationships to other CMS content is nice and tidy:

Payload’s “Edit Link” pane, which includes fields for the link text, the type (Custom URL or Internal Link), the URL itself, and an “Open in new tab” checkbox

Here’s what an internal link looks like when the field contents are deserialized into JSON:

[
  {
    "text": "I’m a little sentence with "
  },
  {
    "children": [
      {
        "text": "a link"
      }
    ],
    "type": "link",
    "linkType": "internal",
    "doc": {
      "relationTo": "thoughts",
      "value": "64a0b2d9d9e429728ac93023"
    }
  },
  {
    "text": " in it."
  }
]

A link.fields setting for the rich text field lets you quickly add custom fields to that link, which is a straightforward thing to do with that JSON that’s stored!

[you] Oh calm down Matt, storing HTML in a database isn’t that bad and it’s easy to pull straight into a page without any nonsense. How could I possibly want this?

  1. You can serialize the JSON into whatever you want. Markdown? Cool. HTML that’s presented a little differently for an RSS feed? No problem.
  2. Deserializing is cleaner and less error-prone. How many times have you done some gross string manipulation or regex to mangle HTML to suit your needs?
  3. You can store whatever you want in that structure. I was looking at Plate when I realized that facilitating comments and collaborative features could be a simple matter of storing associated data right on the thing it discussed. That’s wild! You could do just about anything and keep it tidy and structured.

It’s not all roses; I spent more time fiddling with remark and Slate than I did the rest of my initial content modeling. Getting the import script to match the way Payload stores JSON took effort. Adding support for a <small> tag took effort. I still haven’t figured out how to handle Markdown footnotes.

But these are likely things I’d figure out once, use across most projects, and season to taste with some basic competence I’m still hammering out.

Statamic is all over this with its Bard field. Keystone proudly shows off its lovely Document field. You and I will look back at our rudimentary little HTML text boxes someday and have a good awkward laugh, because these abstract syntax trees are the way.

CommonJS

The one thing that slowed me down a few times and gives me pause is that Payload is a CommonJS project.

This is the great war du jour of the JavaScript ecosystem, where most modern packages are ESM modules or moving in that direction. Some packages are just old, and others are deliberate holdouts for their own reasons. What this means for someone working with the project is that using packages can be tricky and limiting. You’ll need to know your way around the ecosystem to navigate your bundling and transpiling issues.

Workarounds are to downgrade packages pre-ESM versions, use Rollup, or figure out how to transpile them.

Web Deployment

I’m self-hosting a few Node.js apps now that PM2 runs like a champ. It’s quick to install, and not unlike supervisor: its only purpose is to keep a Node.js app running.

Ploi includes a GUI for setting up, spawning, and restarting Node.js apps.

Ploi control panel’s “NodeJS” site section, where PM2 is configured to run `yarn serve` and use port 3000

PM2 keeps the app running and I use nginx to handle SSL and reverse proxy it, which is a common pattern. If you’ve done it once, you can do it a bunch of times.

If you’re comfortable poking around a VPS, it shouldn’t be a massive effort to set up even if it’s a new adventure. Let me know if you want help!

You could also host Payload with Netlify or Vercel.

Unfinished Business

This is still only a long hot take, and I don’t feel comfortable enough pitching Payload for a Craft-like client project. Still on my list:

  • Figure out live preview.
    I have a rudimentary Astro front end running via SSR that can very quickly preview edits with a page refresh, and somebody’s already working on a visual editor plugin that looks promising.
  • Lock down access.
    I’m only playing with an open GraphQL API right now, not yet using JWT tokens or any fine-grained permissions. There’s built-in support for these things, I’m just currently working with a YOLO security model.
  • Moar fields!
    I need to make a custom field type, maybe a star field or something, just to test the waters. It seems like it could actually be fun.
  • Really finish the front end.
    The Astro Node.js SSR preview works great, but I’m sure I’ll learn more transitioning the front end away from Markdown/Markdoc and getting all its content via GraphQL. I’ve only just looked at the first-party SEO plugin, and it looks like that’ll be helpful.

Final Thoughts

Payload CMS is not Craft CMS in the Node.js ecosystem. They’re different applications at different stages in their evolution.

I’m cautious about the Payload team’s business model and what it will mean for the CMS and its plugins long term, I’m optimistic that I can figure out how to navigate the CommonJS/ESM conundrum, and I’m eager to keep having fun with Payload. It’s been a rewarding little adventure so far!

If you see anything I got wrong, have something else I should look at, or have thoughts to share I’d love to hear from you.

Footnotes

  1. How does anyone evaluate things like this without porting a giant project or pitching one and hoping it works out?