Payload CMS, content defined in code
Content management systems usually stand beside the application. You run a separate service, configure it through a panel, then fetch data over an API and wrestle with cross origin rules.
Payload inverts that arrangement. Since version three it installs straight into a Next.js application, admin panel and backend included. There is no separate service and no API layer to maintain, and content queries are ordinary calls from a server component.
The second difference matters just as much. Content structure lives in TypeScript files rather than in a graphical interface. Collections, fields, and access rules are code that sits in the repository, goes through review, and carries a change history.
The project's situation
Before the code, one matter that bears on the decision.
Payload was acquired by Figma in June 2025. The project itself stayed open under a licence permitting any commercial use, the code remains public, and development continues.
What changed is the hosting offering. Signups for the managed service run by its makers were paused after the acquisition and remain paused, while existing customers kept access. The practical conclusion is that a new project must be deployed yourself: on your own server, in a container, or with a provider such as Vercel.
That is not an obstacle, since the system was conceived from the start as something you run yourself, with hosting as an add on. It is worth knowing before promising a client a finished subscription service, though.
First configuration
npx create-payload-app@latest my-projectA collection describes one content type. Here is a blog with articles and authors.
import type { CollectionConfig } from 'payload'
export const Articles: CollectionConfig = {
slug: 'articles',
admin: { useAsTitle: 'title' },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user)
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'published', type: 'checkbox', defaultValue: false }
]
}Several things come out of that description at once: database tables, an admin panel for editing, API endpoints, and TypeScript types for the frontend. One file instead of four places kept in agreement by hand.
The types matter more than they appear to. Generated definitions flow straight into page code, so a typo in a field name is a compile error rather than a blank spot on the page discovered by a reader.
Access control
This is where a code based approach wins most clearly over configuration clicked together in a panel.
Permissions are written as functions returning a boolean or a query condition. A rule can depend on a role, on document ownership, on the time of day, or on anything expressible in code.
access: {
read: ({ req }) => {
if (req.user?.role === 'editor') return true
return { published: { equals: true } }
},
delete: ({ req }) => req.user?.role === 'admin'
}Returning a condition rather than a boolean is the best idea in the whole system. The condition sinks into the database query, so a reader without an account sees only published articles, and filtering happens in the database rather than after loading everything into memory.
Rules cover every access path at once: the panel, the API, and local calls. That means permissions cannot be bypassed accidentally by fetching data another way, which happens regularly in systems where the API is configured separately.
Individual fields carry their own rules too. An internal editorial note can be visible to the team only, even though the article itself is public.
The local API
Since the system runs in the same process as the application, you fetch content without a network request.
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function ArticlePage({ params }) {
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'articles',
where: { slug: { equals: params.slug } },
limit: 1
})
return <article>{result.docs[0]?.title}</article>
}That call goes straight to the database, skipping the HTTP layer. Serialisation drops out, network latency drops out, and so does the question of the API address across environments.
The performance consequence shows most clearly during static generation. Building a thousand pages means not a thousand network requests to your own server but a thousand database queries, which is orders of magnitude faster.
The conventional API works as well, so a mobile app or a separate frontend still has something to connect to. Choosing between them depends on whether the content consumer runs in the same process.
Blocks, or building pages from parts
A collection with fixed fields suffices for a blog and stops sufficing for marketing pages, where every subpage looks different.
A field type composed of blocks solves that without giving up typing. You define a set of available sections, and the editorial team arranges a page from them in any order.
{
name: 'sections',
type: 'blocks',
blocks: [
{
slug: 'hero',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'textarea' }
]
},
{
slug: 'gallery',
fields: [
{ name: 'images', type: 'upload', relationTo: 'media', hasMany: true }
]
}
]
}On the frontend each block maps to one component, and the page renders by walking the list. Types generated from the configuration distinguish the variants, so the compiler flags a missing handler for a new block rather than allowing a silent rendering gap.
There is a trap worth noting. Too rich a block set turns a content system into a page builder, and the editorial team starts designing layouts instead of writing. A dozen or so sections with clear purposes produce a better result than forty variants differing in details.
Naming is a good test. A block named after its role on the page, a customer testimonials section for instance, gets used where it should. A block named after its appearance, two columns with an image on the left for instance, gets used everywhere, and after a year nobody can tell eight similar variants apart.
Hooks and business logic
Every document operation can carry a function running before or after the write. That is where anything meant to happen automatically belongs.
hooks: {
beforeChange: [
({ data }) => {
if (!data.slug && data.title) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
}
return data
}
],
afterChange: [
async ({ doc }) => {
await fetch(`${process.env.APP_URL}/api/revalidate?slug=${doc.slug}`)
}
]
}The second of those hooks solves a problem that always appears with static generation: a page built once does not know the content changed. Triggering revalidation after a write means publishing an article updates the page within seconds, without rebuilding the whole site.
Keep only data related logic in hooks. Sending notifications, generating thumbnails, and similar work belong in a queued job, since a hook running synchronously stretches the save time the editorial team experiences, and a failure in it can block publication.
Versions and multiple languages
Two features that decide the choice of system on a serious site and are easy to overlook on a prototype.
Versioning records a document's change history and allows returning to an earlier state. A separate drafts mechanism keeps the published version and the working version side by side, so the editorial team prepares changes without affecting what readers see.
versions: {
drafts: { autosave: { interval: 2000 } },
maxPerDoc: 50
}Set the per document version cap deliberately rather than by reflex. By default the system keeps a hundred versions per document and prunes the oldest itself, so history does not grow without end. The database swells only once you put a zero there, which in this setting means "keep everything", and pair that with autosave every two seconds.
Multiple language support works at the level of an individual field. You mark which fields are translated, and the system keeps a separate value per language for them. The identifier, relations, and publication date stay shared, so you do not end up with two independent documents to synchronise by hand.
When fetching content you state the language and the system returns the matching values. Settle straight away what should happen on a missing translation: show the base version or hide the document in that language. The default behaviour suits a site where everything is translated and confuses the reader on a partially translated one, where text in a foreign language turns up unannounced among the rest. Deciding this at the start is cheaper than fixing it after a complaint from the editorial team.
Database and hosting
The system supports document and relational databases, and the choice carries real consequences.
A relational database gives schema migrations, referential integrity, and the option of querying from outside the system. It pairs well with the data access layer covered in the piece on Prisma if part of the application reaches the data directly.
A document database is more convenient with nested structures and looser about schema changes. The cost is no enforced consistency across relations.
For serverless deployment, remember two things. The first is the database connection pool, since every function instance opens its own and the limit is reached sooner than expected. A connection pooler solves that. The second is file storage, since a function's file system is ephemeral, so images and attachments must go to object storage.
Files and images
Media handling is part of the system rather than an add on, and it deserves configuring before the editorial team uploads its first five hundred photos.
A collection with uploads enabled accepts files and generates size variants from a list you provide. A variant is produced once, at upload time, so the site does not recompute images on every request.
upload: {
imageSizes: [
{ name: 'thumbnail', width: 400 },
{ name: 'card', width: 800 },
{ name: 'full', width: 1600 }
],
mimeTypes: ['image/*']
}Make the alternative text field required. It is one of the few things that cannot be added later in bulk, since the description must be written by a person looking at the photo, and the backlog grows faster than anyone clears it.
The storage destination changes through a plugin, with no changes to page code. By default files land on disk, which suffices on your own server and fails under serverless deployment. Moving to object storage is a few lines of configuration, while relocating already uploaded files needs a separate script, so settle it at the start.
Size the variants to the site's actual layout rather than to round numbers. Three variants matching the three places a photo genuinely appears produce a smaller storage bill and a faster site than six variants kept just in case.
Payload against the alternatives
| Option | Strength | Weakness | Pick it when |
|---|---|---|---|
| Payload | Configuration in code, one app with the frontend | Requires deploying it yourself | A Next.js project with a developer team |
| Strapi | Panel for building the model, large community | A separate service to maintain | A content model built without a developer |
| Sanity | Managed service, live editing | Billed by traffic and documents | An editorial team working in parallel |
| Contentful | Maturity, enterprise support | High price, rigid model | A large organisation with compliance needs |
The split runs along one question: who defines the content structure. If a developer does, configuration in code wins, since it goes through review and versioning. If an editorial team should do it without technical help, a graphical panel fits better.
The second question concerns deployment. Managed services take server and database maintenance off your hands at the cost of a bill that grows with traffic and dependence on one vendor. Running it yourself inverts that arrangement.
Common mistakes
The first is planning on managed hosting from the makers. Signups are paused, so deployment must be planned for yourself from the project's start.
The second is fetching content over an HTTP request to your own application. Since the system runs in the same process, a local call is faster and simpler.
The third is treating a hidden field in the panel as a security measure. Visibility in the interface is not the same as an access rule, and data without a rule will come out through the API.
The fourth is skipping migrations on a relational database. Changing a field in configuration does not itself change the production table, so omitting migrations ends in schema drift.
The fifth is keeping files on the file system under serverless deployment. They vanish after every deploy, and that is usually discovered a week later.
The sixth is promising the editorial team they can change the content structure themselves. Here a model change is a code change and a deploy, so expectations deserve setting at the start.
FAQ
Is Payload free?
Yes, the code is open under a licence permitting any use including commercial, with no licence fees. You pay only for infrastructure: a server or functions, a database, and file storage.
What happened after the Figma acquisition?
The project stayed open and under development, and the licence did not change. Signups to the hosting service run by its makers were paused, though, so new deployments must run on your own infrastructure or with a provider of your choice.
Do I need Next.js?
To use version three fully, yes, since the system installs as part of a Next.js application. Content can be consumed from anywhere over the API, though, so a mobile app or a frontend in another technology still has something to connect to.
Can an editorial team manage without a developer?
Day to day editorial work, yes, since the admin panel is complete and approachable. Changing the content structure requires editing code and deploying, so a team without technical support is often better served by Strapi or a managed service.
Which database should I pick?
Relational if you want schema migrations, referential integrity, and the option of querying from outside the system. Document based if structures are deeply nested and change often. When in doubt, relational is the safer default.
Documentation sits on the project site, and the code in the GitHub repository.