By Matija — full-stack developer running Payload CMS across multiple production projects for clients ranging from local Slovenian businesses to US-based SaaS companies. Tested on Payload v3.85, PostgreSQL, Next.js 16.2.7, self-hosted on VPS with Docker.
Every few weeks, a thread appears on Reddit or Discord that goes something like this: "I love the code-first approach, but before I commit — what's the ugly truth about Payload in production?" The answers that follow are a mix of genuine experience and forum enthusiasm, and it can be hard to tell which is which.
I have been building with Payload in production long enough to give you a concrete answer to every concern that comes up in those threads. Not the marketing version, and not the dismissive "it's fine, just try it" version either. Here is what I have actually run into, what I have had to build around, and what the official docs quietly skip over.
"How painful are updates really?"
The honest answer is: more than "bump and forget," less than WordPress plugin hell.
Payload ships fast. The team is genuinely one of the most responsive in open source — same-day bug fixes and same-week feature responses happen regularly. But that velocity means the changelog matters. There have been breaking changes between minor versions, particularly around the Drizzle migration flow, which went through several behavioral shifts in early v3.
The specific trap most teams fall into is running push: true in development and then discovering in production that Payload has been tracking a dev-mode marker, blocking your migrations. The fix requires switching your project to migration-only mode, generating a clean baseline, and adding payload migrate to your CI pipeline. I have written a step-by-step guide on exactly this: Stop Running Payload CMS in Push Mode.
The second update-related pain point is schema evolution on live databases. Adding fields is safe. Renaming fields, changing relationship structures, or altering field types requires careful additive migrations — you add the new column, backfill data via a job, then drop the old one in a separate release. There is no magic here; it is standard database discipline, but Payload does not enforce it for you. My guide on schema evolution without data loss covers the full pattern.
The practical approach across projects: pin versions per project, batch upgrades, read the changelog before bumping, and run payload migrate:status after every upgrade. Across multiple production apps the actual time spent on Payload upgrades is small — but it is never zero.
"Does the admin UI hold up when the database gets larger?"
At 20,000–80,000 records, yes — with conditions.
The admin UI stays responsive when your collections have proper database indexes on the fields you sort and filter by. Without indexes, list views slow down noticeably once you pass a few thousand records because Postgres has to do full table scans on every sort. Payload's Indexes documentation covers how to add them, but the docs do not tell you which fields to index — that depends on your access patterns.
The fields to index first: createdAt, updatedAt, any field you use in defaultSort, any field used as a filter in your admin.defaultColumns, and any relationship field used in where queries. The PostgreSQL adapter exposes this directly on your field config.
The defaultColumns trap is real and catches people off guard. If you add too many columns to the admin list view — especially relationship fields — Payload has to resolve all of them for every row on every page load. Keep defaultColumns lean and reserve deep relationship resolution for individual document views.
Where things genuinely get slow is wide documents with many nested blocks and heavy Lexical content. The editor and live preview can feel sluggish there. The solution the community converges on is blocksAsJSON for non-editorial blocks, and being deliberate about block nesting depth. For the frontend query side, the Payload data enrichment pattern — keeping your page payloads lean and fetching related data in a second targeted call — also prevents the admin from trying to resolve more than it needs to.
The Local API helps here too. Because you are hitting the database directly without an external API layer, there is no HTTP round-trip overhead. The performance ceiling is your Postgres instance and your query design, not Payload itself.
"Migrations are painful — Drizzle has quirks"
This is accurate. The Drizzle migration flow in Payload is powerful but has a learning curve that the official docs understate.
The key things to know:
Payload generates Drizzle migration files from your config diff. If you manually edit a generated migration or your database state drifts from what Payload expects, the next migration generation can produce unexpected output. The safest workflow is treating generated migration files as read-only and making all schema changes through config, never directly in the database.
The second quirk: Payload's migration runner does not detect or handle destructive changes gracefully. Renaming a collection slug, for example, looks like a drop-and-create to Drizzle, and you will lose data if you run it naively. You have to intercept this with a custom migration that renames the table directly before Payload's generated migration runs.
Direct database access via payload.db.drizzle is available for cases where you need to do things Payload's migration system cannot express cleanly. The Payload Postgres adapter guide covers safe patterns for running raw Drizzle queries alongside Payload's managed schema.
One more practical note: never run payload migrate at application startup in a distributed environment. If two containers start simultaneously, both attempt the migration runner, and you get race conditions. The correct pattern is a one-shot migration step in your deployment pipeline — a separate job that runs payload migrate before the new containers come up. The full reasoning and implementation are in Stop Runtime Payload Migrations in Distributed Systems.
"The auth is very basic"
This is the most widely confirmed production pain point, and the official docs do not warn you about it clearly enough.
Payload's built-in authentication covers email/password with JWT and HTTP-only cookies. That is solid for a single-tenant app with internal users. The moment you need any of the following, you are building on top of Payload's auth rather than using it:
Social login (Google, GitHub, etc.)
Magic links
Multi-factor authentication
Passkeys
SSO or SAML
Multi-tenant scoped sessions where a user belongs to one tenant and cannot access another
The community solution that has emerged is Better Auth wrapped as a Payload plugin. The Swiss agency quoted in the thread mentioned they built exactly this pattern — an internal plugin that standardizes Better Auth across all their projects. I have gone through the same exercise.
For the cookie and CSRF side — which causes silent 401s in production even with basic auth — the implementation details matter more than the docs suggest. The Payload cookie auth guide and the Next.js App Router CSRF fix both address concrete production failures I have hit personally.
The enterprise tier locks SSO/SAML behind a paywall, which for solo developers and small agencies means you build it yourself or use community plugins. The community plugin ecosystem for auth is functional but not as mature as what you find in the WordPress or Strapi worlds. Budget for this if advanced auth is a requirement.
"The form builder plugin is underpowered"
Confirmed. The official @payloadcms/plugin-form-builder covers the basic use case — a set of field types, a submission collection, email notifications — but breaks down quickly for real-world form requirements.
The specific gaps: conditional field logic is limited, multi-step forms require custom implementation, and the submission handling does not include anything like webhook delivery, CRM integration, or rate limiting out of the box. The plugin also does not give you a clean API for rendering forms on the frontend beyond the basic field type map.
What teams actually do in production: build forms outside the form builder. Either a dedicated forms collection with a custom block-based field structure, or a completely separate form handling service (Typeform, Tally, custom API route) that feeds submissions into a Payload collection as a record store. The form builder plugin is useful for simple contact forms. For anything beyond that, treat it as a starting point and expect to extend it significantly or bypass it.
"The job queue isn't enough for complex orchestration"
Partially true, with context.
Payload's Jobs Queue handles background tasks well: retries, concurrency keys, worker/web role separation, scheduled execution. For the majority of production use cases — sending emails, processing imports, syncing external APIs, triggering reindex jobs — it is sufficient.
Where it falls short is stateful workflow orchestration: long-running processes that span hours or days, complex branching logic between steps, or fan-out patterns where one job spawns dozens of parallel jobs that need to be aggregated. The Payload queue does not natively support these patterns.
The async hooks transaction trap is a related production gotcha worth calling out separately: if you fire background work inside a Payload hook without explicitly detaching it from the request transaction, the work rolls back silently if the request fails. This is one of those bugs that only appears in production under specific timing conditions. The full explanation and fixes are here.
"i18n was rough in early v3"
Accurate for the early v3 period. As of the current v3 release the core localization functionality — locale-scoped fields, locale: 'all' for build-time fetching, per-locale draft publishing — is stable and production-ready.
The edge cases that still bite: nested blocks inside localized collections can behave unexpectedly when you mix localized and non-localized fields within the same block. The localizeStatus field (which enables independent per-locale draft/publish states) is particularly sensitive to block nesting depth. I have documented the per-locale draft publishing setup and the locale 'all' fetch pattern including the known edge cases.
If you are building a multilingual site on Payload today, the localization system works. Budget for careful testing of your specific block structure and field configuration rather than assuming the core patterns will transfer without adjustment.
"The Local API typing is annoying — depth param returns null | string | Type"
This is a real friction point in daily development. Payload's Local API is typed at the collection level, but the depth parameter controls how many levels of relationships get populated at query time — and TypeScript cannot know at compile time which depth you will pass. The result is union types at every relationship field: the field might be an ID string (unpopulated) or a full document (populated), and TypeScript reflects both possibilities.
The pattern that removes most of the friction: create typed wrapper functions for your most-used queries that assert the populated shape after fetching. One function per query shape, with an explicit return type that matches the depth you actually pass. It is a small amount of boilerplate and the type safety is worth it. You stop fighting the generics every time you access a nested relationship.
There is no framework-level fix for this — it is an inherent tension between runtime-configurable depth and compile-time type safety. GraphQL resolves it with fragments, which is what one commenter in the thread missed. The Local API does not have an equivalent. This is a genuine ergonomic gap, not a bug, and worth knowing before you build a project with deep relationship structures.
"You over-engineer yourself into a corner"
The most useful warning in the entire thread, and the one that requires the most experience to appreciate.
Payload's flexibility means there is almost always a way to model something. The problem is that there are usually three or four ways to model the same thing, and the decision you make at the start is very hard to undo. Multi-tenancy is the clearest example: you can scope tenants at the collection level, at the access control level, via a global users collection with a membership array, or via row-level policies. Each approach has different performance, security, and maintenance characteristics, and migrating between them in production is painful.
The other discipline that matters: block structure. Payload's blocks system is powerful enough to build an entire visual editor, but every block adds to the config complexity that Payload has to resolve on every admin load. Use block references to share blocks across collections without duplicating config. Keep block nesting shallow. Do not use blocks where a simple field will do.
The over-engineering trap is real, but it is a Payload-amplified version of a general software discipline problem. Teams that bring architectural discipline to Payload ship fast and maintain cleanly. Teams that treat the flexibility as an invitation to model everything as a collection with twelve relationship fields end up with a system that only the original developer can operate.
"It's not a WordPress killer"
Agreed, and framing it that way misunderstands what Payload is for.
WordPress serves a specific market: non-technical site owners who want a GUI-first tool, a massive plugin ecosystem, and no required developer involvement after launch. Payload serves developers building applications where the content model, access control, and business logic need to live in code — auditable, version-controlled, deployable.
These are different products for different jobs. A marketing team managing a blog on WordPress has a better day than they would in Payload's admin. A development team building a multi-tenant SaaS with a content layer has a far better day in Payload than they would in WordPress.
The useful comparison is against Sanity, Strapi, and Contentful — hosted headless CMS platforms. Against those, Payload's advantages are self-hosting (no per-seat costs, data ownership), TypeScript-native config, and the ability to extend the framework itself rather than working within its constraints. The tradeoffs are a less polished plugin ecosystem, auth you have to extend yourself, and the operational overhead of running your own database and file storage. I have covered the detailed tradeoffs in Sanity vs Payload and CMS vendor lock-in if you want the full breakdown.
When I would not use Payload
These are the scenarios where I steer clients and projects away from it:
The primary stakeholder is a non-technical editor who needs a polished GUI experience. Payload's admin is functional and customizable, but it is not a consumer-grade CMS product. Editors who are used to Wordpress or Squarespace will need onboarding. If the developer is not available to maintain and extend the admin as editing needs evolve, the experience degrades.
The project needs enterprise auth (SSO, SAML) and there is no developer budget to implement it. This belongs behind the enterprise tier or requires a community plugin implementation. Neither is plug-and-play.
The team has no one who understands Postgres and migrations. Payload's Drizzle integration gives you a lot of power, and it bites you if you do not know what you are doing with schema changes. A team that has never managed database migrations on a live system will have a rough first production incident.
The project is genuinely just a content site with no application logic. If you are building something where all you need is a way for editors to update page content and blog posts, a simpler hosted CMS like Sanity or even a managed WordPress setup has lower operational overhead. Payload's power becomes cost when you do not need it.
FAQ
Is Payload stable enough for production in 2026?
Yes, for production use on PostgreSQL with a team that can manage migrations. The v3 architecture on Next.js is stable. The remaining rough edges (form builder, advanced auth, deep Lexical performance) are documented workarounds, not blockers.
How often do breaking changes happen?
In v3, significant breaking changes are infrequent. Minor version bumps can introduce config-level changes, particularly around the Drizzle adapter and jobs system. Reading the changelog before bumping is worth the five minutes.
Can Payload handle 100,000+ records?
Yes, with proper indexing and query discipline. Payload sits on Postgres — the database's performance characteristics apply directly. Index your sort and filter fields, keep relationship depth shallow in list views, and there is no CMS-level ceiling at that scale.
What is the minimum viable team size for a production Payload project?
One experienced developer can run a production Payload project solo. The operational overhead is real (database backups, migration discipline, auth configuration) but manageable. The risks increase if that developer leaves and the replacement has no Payload or Postgres experience.
Should I use Vercel or self-host?
For projects with predictable traffic and no long-running jobs, Vercel works. For projects with background jobs, file storage, or cost sensitivity beyond a certain scale, self-hosting on a VPS with Docker is more practical. I wrote a full self-hosted deployment guide covering the Docker + Nginx + Postgres setup.
Conclusion
Payload CMS in production is genuinely good. The team is responsive, the TypeScript-native architecture is the right foundation, and the code-first approach pays dividends on every project that has non-trivial access control or business logic requirements. The Reddit threads that praise it are not wrong.
What they leave out: auth needs a real solution on top of the defaults, the form builder is a starting point, Lexical gets slow on deeply nested documents, migrations require database discipline, and the job queue works for most things but not complex orchestration. These are all solvable problems with known patterns. Going in with that knowledge means you build the right layer from the start rather than discovering it after your first production incident.
Let me know in the comments if you have questions about any of the specific scenarios above, and subscribe for more practical Payload guides as new patterns emerge.