Payload CMS 3.0: Headless CMS for Developers
Payload CMS 3.0 has quickly become the go‑to headless CMS for developers who want full control over content delivery without sacrificing the comforts of a modern framework. It blends a powerful Node.js core with a type‑safe API, a clean admin UI, and an extensible plugin system—all while staying completely framework‑agnostic. In this article we’ll walk through the key concepts, set up a minimal project, and explore real‑world scenarios where Payload shines.
What Makes a Headless CMS “Headless”?
A headless CMS decouples content storage from presentation. Instead of rendering HTML on the server, it exposes content through REST or GraphQL endpoints, allowing any front‑end—React, Vue, Flutter, even static site generators—to consume the data. This architecture gives developers the freedom to choose the best UI stack, implement custom caching strategies, and ship updates without touching the CMS backend.
Because the “head” (the front‑end) is completely separate, you also gain better scalability. The CMS can be hosted on a dedicated server or a serverless platform, while the front‑end can be served from a CDN. Payload 3.0 embraces this philosophy and adds a few modern twists that make the developer experience feel native.
Why Choose Payload CMS 3.0?
Payload is built on top of Express and TypeScript, which means you get strong typing out of the box. The admin UI is generated automatically from your collection definitions, so you spend less time building CRUD interfaces and more time modeling real business data. Moreover, Payload ships with built‑in authentication, role‑based access control, and file handling—features that often require separate services in other headless solutions.
Another standout is the plugin ecosystem. Whether you need SEO fields, multilingual support, or custom validation, there’s usually a plugin ready to plug in. And because plugins are just regular Node modules, you can write your own in a few lines of code.
Core Features at a Glance
- Type‑safe schemas: Define collections with TypeScript interfaces.
- REST & GraphQL APIs: Enable both out of the box, toggle per collection.
- Rich media handling: Built‑in upload, image transformations, and cloud storage adapters.
- Authentication & ACL: JWT, email/password, OAuth, and granular permissions.
- Extensible plugins: Hooks, custom routes, and admin UI extensions.
These features combine to create a developer‑first platform that scales from a single‑page app to a global e‑commerce network.
Getting Started: Project Setup
First, create a new Node project and install Payload as a dependency. The CLI scaffolds a ready‑to‑run server with a default admin UI.
# Terminal commands
npm init -y
npm i payload@3
npx payload create my‑blog
cd my‑blog
npm run dev
After the server starts, navigate to http://localhost:3000/admin. You’ll see the auto‑generated admin panel with a “Posts” collection already defined. The default credentials are admin@payloadcms.com / password, which you should change immediately.
Defining Your First Collection
Open src/collections/Posts.ts. Payload uses a simple object schema where each field describes its type, validation, and UI options.
import { CollectionConfig } from 'payload/types';
const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText',
},
{
name: 'published',
type: 'checkbox',
defaultValue: false,
},
{
name: 'coverImage',
type: 'upload',
relationTo: 'media',
},
],
};
export default Posts;
Notice the admin.useAsTitle property—Payload will display the title field as the primary label in the UI. Save the file, restart the dev server, and you’ll see the new fields reflected instantly.
Fetching Content with the REST API
Payload automatically exposes a REST endpoint for each collection at /api/:collection. Let’s retrieve the list of published posts using Python’s requests library.
import requests
API_URL = 'http://localhost:3000/api/posts?where[published][equals]=true'
def fetch_posts():
response = requests.get(API_URL)
response.raise_for_status()
data = response.json()
for post in data['docs']:
print(f"📝 {post['title']} (slug: {post['slug']})")
if __name__ == '__main__':
fetch_posts()
The query string uses Payload’s built‑in where syntax to filter on the published boolean. The response includes pagination metadata, making it easy to implement infinite scroll or server‑side pagination in your front‑end.
GraphQL: A More Flexible Alternative
If you prefer GraphQL, enable it in payload.config.ts and then query the same data with a single request.
# payload.config.ts snippet
export default buildConfig({
// ...other config
graphql: {
schemaOutputFile: path.resolve(__dirname, 'generated-schema.graphql'),
disable: false,
},
});
Now, using Python’s gql library:
from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport
transport = RequestsHTTPTransport(url='http://localhost:3000/graphql')
client = Client(transport=transport, fetch_schema_from_transport=True)
query = gql('''
{
Posts(where: {published: {equals: true}}) {
docs {
title
slug
content
}
}
}
''')
result = client.execute(query)
for post in result['Posts']['docs']:
print(f"🔎 {post['title']} – {post['slug']}")
GraphQL lets you fetch exactly the fields you need, reducing payload size and simplifying front‑end data mapping.
Modeling Complex Content: Relational Fields
Most real‑world projects require relationships—authors, tags, categories, or even nested components. Payload treats relationships as first‑class fields, and you can define them as one‑to‑many, many‑to‑many, or polymorphic.
Example: Authors & Posts
First, create an Authors collection.
import { CollectionConfig } from 'payload/types';
const Authors: CollectionConfig = {
slug: 'authors',
admin: { useAsTitle: 'name' },
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'bio', type: 'textarea' },
{
name: 'avatar',
type: 'upload',
relationTo: 'media',
},
],
};
export default Authors;
Then, link the Posts collection to Authors using a relationship field.
// In Posts.ts
{
name: 'author',
type: 'relationship',
relationTo: 'authors',
required: true,
}
When you fetch a post, you can populate the author data with the populate query param.
API_URL = 'http://localhost:3000/api/posts?populate=author'
This approach eliminates the need for manual joins and keeps your data model declarative.
Custom Endpoints: Extending the API
Sometimes the built‑in CRUD endpoints aren’t enough. Payload lets you add custom Express routes that run alongside the core API. Below is a simple “search” endpoint that performs a full‑text search across titles and content.
// src/routes/search.ts
import { Request, Response } from 'express';
import payload from 'payload';
export default async function search(req: Request, res: Response) {
const { q } = req.query;
if (!q) {
return res.status(400).json({ message: 'Missing query param ?q=' });
}
const results = await payload.find({
collection: 'posts',
where: {
or: [
{ title: { like: `%${q}%` } },
{ content: { like: `%${q}%` } },
],
},
limit: 20,
});
res.json({ results });
}
Register the route in payload.config.ts:
// payload.config.ts
import search from './src/routes/search';
export default buildConfig({
// ...other config
routes: [
{
path: '/api/custom-search',
method: 'get',
handler: search,
},
],
});
Now a front‑end can call /api/custom-search?q=payload and receive a curated list of matching posts.
Pro tip: Keep custom logic thin and delegate heavy lifting to Payload’s built‑in query engine. This ensures you stay compatible with future version upgrades and benefit from built‑in caching.
Authentication & Role‑Based Access Control
Payload ships with a fully featured authentication system out of the box. By default it supports email/password, but you can add OAuth providers (Google, GitHub) via plugins. Users are stored in the users collection, and each user can be assigned one or more roles.
Roles define granular permissions per collection and per operation (create, read, update, delete). For example, an “Editor” role might have read/write access to posts but only read access to authors.
Defining Roles
// src/collections/Users.ts
import { CollectionConfig } from 'payload/types';
const Users: CollectionConfig = {
slug: 'users',
auth: {
// Enable email/password login
strategies: ['local'],
tokenExpiration: 60 * 60 * 24, // 1 day
cookies: {
secure: process.env.NODE_ENV === 'production',
},
},
fields: [
{
name: 'role',
type: 'select',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'Viewer', value: 'viewer' },
],
defaultValue: 'viewer',
},
// other fields like name, avatar, etc.
],
};
export default Users;
In the collection config, you can reference the role to restrict operations:
// src/collections/Posts.ts (excerpt)
access: {
create: ({ req }) => req.user?.role === 'admin' || req.user?.role === 'editor',
read: () => true,
update: ({ req }) => req.user?.role !== 'viewer',
delete: ({ req }) => req.user?.role === 'admin',
},
This declarative approach keeps permission logic close to the data model, reducing the chance of accidental exposure.
Real‑World Use Cases
E‑Commerce Product Catalog
Many online stores need a flexible product model with variants, images, and inventory tracking. Payload’s upload field handles image optimization, while relational fields let you link products to categories, brands, and discount rules. Combined with a GraphQL API, the front‑end (Shopify‑style React, Next.js) can query only the fields needed for a product page, keeping page loads under 200 ms.
Mobile App Content Hub
Imagine a news app built with Flutter that needs to pull articles, videos, and push notifications. By exposing both REST and GraphQL, you can let the mobile client choose the most efficient protocol. Payload’s JWT authentication integrates seamlessly with mobile token storage, and the built‑in locale field (via the multilingual plugin) lets you serve localized content without a separate translation service.
Multi‑Site, Multi‑Language Corporate Site
Large enterprises often run several brand sites that share a common content repository. With Payload’s global collections you can store site‑wide settings (navigation, footer, SEO defaults) once and reference them from each brand’s front‑end. The multilingual plugin adds language fallback logic, allowing you to maintain a single source of truth while delivering region‑specific copy.
Pro tip: When building multi‑site architectures, use separate collections for brand‑specific content but share globals and media assets. This reduces duplication and simplifies content governance.
Performance & Scaling Considerations
Payload runs on Express, so you can leverage any Node.js performance techniques you already know. Enable HTTP caching headers on API routes, use a reverse proxy (NGINX or Cloudflare) for edge caching, and configure the cache option in the GraphQL config to enable query result caching.
For large media libraries, integrate a cloud storage provider (AWS S3, Cloudinary, or Azure Blob). Payload’s upload field supports custom adapters, letting you offload image processing and CDN delivery to the provider of your choice.
Horizontal Scaling
If you anticipate high traffic, run multiple Payload instances behind a load balancer. Because the admin UI is stateless, you only need to share the underlying MongoDB (or PostgreSQL) database and the media storage bucket. Session data can be stored in Redis, which Payload can be configured to read for JWT revocation lists.
Extending Payload with Plugins
Plugins are simply Node modules that export a function receiving the current Payload config and returning an extended config. The community offers plugins for SEO fields, sitemap generation, and even headless e‑commerce checkout flows.
Below is a minimal “slugify” plugin that automatically generates a URL‑friendly slug from the title field whenever a document is saved.
// plugins/slugify.ts
import { Plugin } from 'payload/config';
import slugify from 'slugify';
const slugifyPlugin: Plugin = (incomingConfig) => {
const { collections } = incomingConfig;
const updatedCollections = collections.map((col) => {
if (!col.fields) return col;
const titleField = col.fields.find((f) => f.name === 'title');
const slugField = col.fields.find((f) => f.name === 'slug');
if (titleField && slugField) {
const originalHooks = col.hooks?.beforeChange ?? [];
const slugHook = async ({ data, operation }) => {
if (operation === 'create' || operation === 'update') {
data