Initializing
My real-world experience choosing Payload CMS 3 for projects that need a modern CMS, TypeScript, PostgreSQL, and an integrated architecture built around Next.js App Router. From Code-First development and the Local API to caching, migrations, authentication, and the trade-offs I encountered along the way.

Choosing the right CMS for a project is rarely as simple as comparing feature lists.
When you're building more than a basic blog or landing page, the decision can affect your architecture, development workflow, deployment process, type safety, database management, and even how much infrastructure you have to maintain later.
On one side, there are traditional CMS platforms like WordPress. They are still perfectly valid tools and can be an excellent choice for many projects. But once you start moving toward a Headless architecture, TypeScript-heavy development, custom APIs, and a modern React-based frontend, you may eventually find yourself working around parts of the system rather than with them.
On the other side, there are cloud-based Headless CMS platforms like Sanity and Contentful. They can be incredibly convenient, especially when you're getting started. However, your infrastructure and data live outside your direct control, and pricing or platform limitations can become more relevant as a project grows.
For some of my recent projects, I decided to take a different approach and started using Payload CMS 3.
Not because I wanted to combine a CMS with Next.js.
In fact, one of the main reasons I chose Payload was the opposite: Payload 3 is already built around Next.js and the App Router architecture.
That means I don't necessarily need to maintain a separate CMS application and then build another layer to connect it to my frontend. The admin panel, API, authentication, CMS logic, and frontend can live inside the same Next.js application.
In this article, I want to share my actual experience with that setup: why I chose Payload, what I really liked about it, and where I ran into problems — especially around Next.js caching and database migrations.
This is not a theoretical comparison or a rewrite of the documentation. These are the things I learned while actually building with it.
The obvious question is:
If tools like Strapi and Directus already exist, why Payload?
For me, the answer came down to a few architectural decisions.
One of the things I found most interesting about Payload 3 was its integrated architecture.
A typical project might look something like this:
Payload takes a different approach.
Because it works directly within a Next.js application, the admin panel, API routes, Server Components, and CMS logic can all exist as part of the same application.
For me, this wasn't just about having fewer folders or repositories.
It simplified several things:
Of course, this architecture is not automatically better for every use case.
If your CMS needs to serve multiple completely independent applications, or if you specifically want your backend and frontend to be deployed and scaled independently, a separate architecture may make more sense.
But for projects where the CMS and frontend are part of the same product, having everything inside one application felt much more natural.
Personally, I prefer defining my data models in code.
I don't particularly enjoy creating a schema by opening an admin interface, clicking through multiple forms, and hoping that the configuration stays consistent across different environments.
With Payload, your collections and fields are defined directly in TypeScript.
For example, a simple blog collection could look like this:
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
},
{
name: 'content',
type: 'richText',
},
],
}
This approach gave me a few important advantages.
First, the schema becomes part of the codebase.
Changes can be version-controlled, reviewed, and tracked just like the rest of the application.
Second, TypeScript becomes a much more meaningful part of the workflow.
When your data structure changes, you can generate and update types based on your Payload configuration. That means you get a much better developer experience when working with CMS data across your application.
For someone who spends most of their time working with React and TypeScript, this feels significantly more natural than managing the entire data model through a graphical interface.
One of the features I quickly started appreciating was Payload's Local API.
Imagine you're inside a Next.js Server Component and you need to retrieve a blog post.
In many architectures, you might do something like this:
await fetch('https://api.example.com/posts/my-post')
But if Payload is running as part of the same application, there is no need to make an unnecessary internal HTTP request just to retrieve your own data.
Instead, you can use Payload directly:
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: {
slug: {
equals: slug,
},
},
})
const post = posts.docs[0]
// Render the post...
}
This doesn't magically solve every performance problem. You still need to think about your database queries, indexes, and caching strategy.
But architecturally, it removes an unnecessary HTTP layer and allows the application to work with Payload in a much more direct and type-safe way.
For me, that simplicity was a significant advantage.
This was probably the area where I spent the most time debugging.
When you're working with the Next.js App Router, you can't always approach rendering with the old mental model of "the server runs again every time someone visits the page."
Caching behavior can become more nuanced.
You need to understand:
Let's say you're retrieving a blog post from Payload.
At first, everything looks straightforward: query the post and render it.
But once you start caching that data for performance reasons, you need a reliable way to invalidate or revalidate it after the content changes.
This is where cache tags became important in my workflow.
For example, a post query can be wrapped in a cache with a deterministic tag:
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import config from '@payload-config'
export function getPost(slug: string) {
return unstable_cache(
async () => {
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'posts',
where: {
slug: {
equals: slug,
},
},
limit: 1,
})
return result.docs[0] ?? null
},
['post', slug],
{
tags: [`post:${slug}`],
},
)()
}
Then, when the content changes, a Payload hook can trigger revalidation for the related data.
Conceptually, it can look like this:
import type { CollectionConfig } from 'payload'
import { revalidateTag } from 'next/cache'
export const Posts: CollectionConfig = {
slug: 'posts',
fields: [
// Fields...
],
hooks: {
afterChange: [
({ doc }) => {
revalidateTag(`post:${doc.slug}`)
revalidateTag('posts')
return doc
},
],
afterDelete: [
({ doc }) => {
revalidateTag(`post:${doc.slug}`)
revalidateTag('posts')
},
],
},
}
The core idea is simple:
Cache aggressively when it makes sense, but always know how that cache will be invalidated.
This gives you more flexibility than choosing between two extremes:
Finding the right balance between performance and fresh data was one of the biggest lessons I learned while working with this architecture.
And honestly, this was the part where I had to revisit my caching strategy more than once.
Database changes are easy when a project is still small.
You add a field, reset the database, start again, and move on.
But once you're dealing with real data, that approach stops being an option.
Adding, removing, or changing a field can affect your production database. At that point, you need to know exactly how the schema is going to move from one version to another.
That's why migrations became an important part of my workflow.
For local development, I usually run PostgreSQL with Docker:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: my_project_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
After changing my collections or database structure, I create and review the migration before deployment.
A simple workflow can look like this:
pnpm payload migrate:create
And then:
pnpm payload migrate
The most important part isn't the command itself.
The important part is treating database changes as intentional, version-controlled changes rather than something that happens automatically and unpredictably during deployment.
Once a project moves beyond the prototype stage, having a proper migration workflow can save you from a lot of painful problems later.
Another reason Payload was appealing to me was its built-in authentication system.
Instead of immediately introducing multiple authentication libraries and services, Payload already provides the foundations for things like:
For example, inside a Server Component, you can use the incoming request headers to identify the authenticated user:
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const payload = await getPayload({
config,
})
const { user } = await payload.auth({
headers: await headers(),
})
if (!user) {
redirect('/login')
}
return (
<div>
<h1>Welcome, {user.email}</h1>
</div>
)
}
What I found even more useful was the access control system.
You can control who is allowed to:
And you can also define access rules at the field level.
For example:
{
name: 'isPublished',
type: 'checkbox',
access: {
update: ({ req }) => {
return req.user?.role === 'admin'
},
},
}
This allows authorization rules to live close to the resources they are protecting.
Instead of scattering all of your access logic across different middleware layers and controllers, a significant part of that logic can be defined directly within your Payload configuration.
This article isn't meant to be an advertisement for Payload.
The architecture comes with trade-offs, and there are definitely situations where it can add more complexity than necessary.
When your CMS, admin panel, and frontend are all part of the same application, the project naturally becomes more complex than a basic Next.js website.
You'll have more dependencies, potentially heavier bundles, and build times that may be longer than what you'd experience in a simple frontend project.
For a small website, that extra complexity may not be worth it.
Deploying a static website is relatively simple.
Once you introduce PostgreSQL, a Node.js runtime, and a CMS, you also take on more operational responsibility.
You need to think about things like:
This isn't necessarily a disadvantage.
It's simply part of owning more of your infrastructure.
For some projects, that control is worth it. For others, it may be unnecessary overhead.
Payload 3 sits in a modern ecosystem that often involves multiple moving parts:
That means version compatibility deserves attention.
Personally, I don't update dependencies blindly just because a newer version is available.
In a project like this, a seemingly small update can affect your build process, generated types, or the behavior of a database adapter.
Keeping the dependency tree stable and updating deliberately has become part of my workflow.
Definitely not.
If you're building a simple landing page or publishing a few articles, Payload might be unnecessary.
Sometimes Markdown is the better CMS.
But for projects that:
Payload can be a very compelling option.
The main reason I chose Payload CMS 3 wasn't because I wanted to connect a CMS to Next.js.
One of the biggest reasons was that Payload 3 is already built around Next.js and the App Router architecture.
That allowed me to work with a more integrated application where the CMS, admin panel, authentication, and frontend could all exist within the same system.
Combined with PostgreSQL, TypeScript, and a proper migration workflow, that architecture ended up being a much better fit for the kind of projects I build than I initially expected.
The experience wasn't completely smooth.
The biggest challenge for me was understanding Next.js caching properly and building a reliable strategy for revalidating data after content changes. Database migrations and dependency compatibility also required more attention than they would in a simpler project.
But in the end, Payload didn't just feel like a CMS attached to my application.
It felt like part of the application architecture itself.
And that's ultimately the biggest reason I decided to use it.

5 practical code quality patterns, component decomposition strategies, and TypeScript hygiene techniques to write production-ready frontend code.

A clear breakdown of state categories in modern React applications: local state, context, server cache, and why Zustand is the go-to lightweight global store.