Fix: Payload CMS Not Working — Collections Not Loading, Auth Failing, or Admin Panel Blank
Part of: React & Frontend Errors
Quick Answer
How to fix Payload CMS issues — collection and global config, access control, hooks, custom fields, REST and GraphQL APIs, Next.js integration, and database adapter setup.
The Problem
The Payload admin panel loads but shows a blank page or crashes:
Error: Cannot read properties of undefined (reading 'collections')Or a collection API returns 403 even for authenticated users:
GET /api/posts → 403 ForbiddenOr the admin panel works but the Next.js frontend can’t fetch data:
const posts = await payload.find({ collection: 'posts' });
// Error: payload is not defined — or — Cannot access local APIOr the database connection fails on startup:
Error: connect ECONNREFUSED 127.0.0.1:27017Why This Happens
Payload CMS is a headless CMS built on Next.js. Since version 3.0, Payload is not a standalone service — it is a Next.js plugin that runs inside your Next.js application. The admin panel is a set of App Router routes that Payload injects via withPayload(), and the API endpoints are route handlers mounted under /api. This means anything that breaks the Next.js build (a TypeScript error in your config, a missing environment variable, an incompatible Next.js version) breaks the admin panel as well, and the error often surfaces as a blank page rather than a clear stack trace.
Four constraints drive most “not working” reports. Payload is configured through a TypeScript config — payload.config.ts defines collections, globals, access control, and hooks. A typo here causes the entire admin to fail on load because the config is imported synchronously during the bootstrap. Access control is deny-by-default — without explicit access functions, the operations are locked. Each of read, create, update, and delete needs its own function; returning false or omitting it blocks the operation. The local API is server-only — payload.find(), payload.create(), and friends are available in Server Components, route handlers, and Server Actions, but not in Client Components. Trying to import the Payload client into a 'use client' file fails because Payload pulls in Node-only dependencies.
Database adapters are separate packages. Payload supports MongoDB via @payloadcms/db-mongodb (which wraps Mongoose) and Postgres via @payloadcms/db-postgres (which wraps Drizzle ORM). The two adapters have different field types, different migration tooling, and different connection pool semantics. Switching from MongoDB to Postgres mid-project is a migration, not a config swap — schemas regenerate, IDs change from ObjectId strings to integers, and array fields move to relation tables.
Platform and Environment Differences
Payload 3 is App Router-only. The admin panel and API routes ship as App Router segments, so a Pages Router app cannot host Payload 3 — you must migrate to App Router first (Pages Router is still supported by Payload 2.x, but that branch is in maintenance). The withPayload() wrapper in next.config.mjs injects Payload’s segments at build time. If your Next.js version is below the supported range, the wrapper silently no-ops and the admin route returns 404.
Database choice changes a lot. MongoDB (Mongoose adapter) gives you flexible schemas, automatic ID generation as 24-character ObjectId strings, and no explicit migrations — schema changes apply immediately. The trade-off is that complex queries with deeply nested filters are slow on large collections without explicit indexes. Postgres (Drizzle adapter) gives you SQL queries, foreign-key joins, and explicit migrations via npx payload migrate:create and npx payload migrate. You must commit and run migrations to keep schemas in sync across environments. Postgres also handles array and blocks fields as separate relation tables, which changes the shape of payload.find() results when depth: 0 is used.
Hosting matters even more than the database. On Vercel, the recommended deployment uses Vercel Postgres or Neon for Postgres, or MongoDB Atlas. Vercel’s serverless functions cap at 10 seconds on Hobby and 60 on Pro, so heavy media uploads or migrations must run as background jobs. On Cloudflare Pages or Workers, Payload does not run because the Node-only dependencies (Mongoose, sharp for image processing) are not Workers-compatible — self-host or use a Node-based PaaS instead. On self-hosted Node (Railway, Render, Fly.io, Docker), you get full Node APIs but must manage uploads, image processing, and disk persistence yourself. For S3-compatible storage, install @payloadcms/storage-s3; for Cloudflare R2, point the S3 plugin at the R2 endpoint; for Vercel Blob, install @payloadcms/storage-vercel-blob. The storage plugin is collection-scoped, so each upload collection chooses its own adapter — you can mix local disk in dev with S3 in production via env-aware config.
Fix 1: Set Up Payload with Next.js
npx create-payload-app@latest
# Or add to existing Next.js project:
npm install payload @payloadcms/next @payloadcms/richtext-lexical @payloadcms/db-mongodb// payload.config.ts
import { buildConfig } from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { Posts } from './collections/Posts';
import { Users } from './collections/Users';
import { Media } from './collections/Media';
import { SiteSettings } from './globals/SiteSettings';
export default buildConfig({
// Admin panel settings
admin: {
user: Users.slug, // Collection used for admin authentication
meta: {
titleSuffix: '— My CMS',
},
},
// Collections (content types)
collections: [Users, Posts, Media],
// Globals (singleton data)
globals: [SiteSettings],
// Rich text editor
editor: lexicalEditor(),
// Database
db: mongooseAdapter({
url: process.env.DATABASE_URI!,
// Or for Postgres:
// import { postgresAdapter } from '@payloadcms/db-postgres';
// db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI } }),
}),
// TypeScript output
typescript: {
outputFile: 'src/payload-types.ts',
},
// Secret for auth tokens
secret: process.env.PAYLOAD_SECRET!,
});Fix 2: Define Collections with Access Control
// collections/Posts.ts
import type { CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'publishedAt', 'author'],
},
// Access control — who can do what
access: {
// Anyone can read published posts
read: ({ req }) => {
if (req.user) return true; // Logged-in users see all
return { status: { equals: 'published' } }; // Public sees published only
},
// Only authenticated users can create
create: ({ req }) => !!req.user,
// Authors can update their own posts, admins can update any
update: ({ req }) => {
if (req.user?.role === 'admin') return true;
return { author: { equals: req.user?.id } };
},
// Only admins can delete
delete: ({ req }) => req.user?.role === 'admin',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
minLength: 5,
maxLength: 200,
},
{
name: 'slug',
type: 'text',
unique: true,
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText', // Uses the configured editor (Lexical)
},
{
name: 'excerpt',
type: 'textarea',
maxLength: 300,
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
required: true,
admin: { position: 'sidebar' },
},
{
name: 'tags',
type: 'array',
fields: [
{ name: 'tag', type: 'text', required: true },
],
},
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
{
name: 'publishedAt',
type: 'date',
admin: {
position: 'sidebar',
date: { pickerAppearance: 'dayAndTime' },
},
},
],
// Hooks — run logic on CRUD operations
hooks: {
beforeChange: [
({ data, operation }) => {
// Auto-generate slug from title
if (operation === 'create' && data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
// Set publishedAt when status changes to published
if (data.status === 'published' && !data.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
},
],
},
// Versions / drafts
versions: {
drafts: true,
maxPerDoc: 10,
},
};// collections/Users.ts
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
auth: true, // Enables authentication
admin: {
useAsTitle: 'email',
},
access: {
read: () => true,
create: ({ req }) => req.user?.role === 'admin',
update: ({ req, id }) => req.user?.role === 'admin' || req.user?.id === id,
delete: ({ req }) => req.user?.role === 'admin',
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'role',
type: 'select',
defaultValue: 'editor',
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
],
access: {
update: ({ req }) => req.user?.role === 'admin',
},
},
],
};
// collections/Media.ts
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'public/media',
mimeTypes: ['image/*', 'application/pdf'],
imageSizes: [
{ name: 'thumbnail', width: 300, height: 300, position: 'centre' },
{ name: 'card', width: 768, height: 432, position: 'centre' },
],
},
access: {
read: () => true,
create: ({ req }) => !!req.user,
},
fields: [
{ name: 'alt', type: 'text', required: true },
{ name: 'caption', type: 'text' },
],
};Fix 3: Fetch Data in Next.js
// lib/payload.ts — get the Payload instance
import { getPayload } from 'payload';
import config from '@payload-config';
export async function getPayloadClient() {
return getPayload({ config });
}// app/blog/page.tsx — Server Component
import { getPayloadClient } from '@/lib/payload';
export default async function BlogPage() {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 10,
depth: 1, // Populate relationships 1 level deep
});
return (
<div>
{posts.docs.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
// app/blog/[slug]/page.tsx — Single post
export default async function PostPage({ params }: { params: { slug: string } }) {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
where: { slug: { equals: params.slug } },
limit: 1,
depth: 2,
});
const post = posts.docs[0];
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
{/* Rich text rendering depends on your editor */}
<div>{/* render post.content */}</div>
</article>
);
}
// Static generation
export async function generateStaticParams() {
const payload = await getPayloadClient();
const posts = await payload.find({
collection: 'posts',
where: { status: { equals: 'published' } },
limit: 100,
});
return posts.docs.map(post => ({ slug: post.slug }));
}Fix 4: REST and GraphQL APIs
Payload auto-generates REST and GraphQL endpoints:
// REST API — available at /api/{collection}
// GET /api/posts → List posts
// GET /api/posts/:id → Get single post
// POST /api/posts → Create post
// PATCH /api/posts/:id → Update post
// DELETE /api/posts/:id → Delete post
// Query parameters
// GET /api/posts?where[status][equals]=published&sort=-publishedAt&limit=10&page=1&depth=1
// Authentication
// POST /api/users/login { email, password } → Returns token
// Headers: Authorization: JWT <token>// Client-side fetching
async function fetchPosts() {
const res = await fetch('/api/posts?where[status][equals]=published&limit=10', {
headers: {
// Include auth token if needed
Authorization: `JWT ${token}`,
},
});
const data = await res.json();
return data.docs;
}
// GraphQL — available at /api/graphql
/*
query {
Posts(where: { status: { equals: published } }, limit: 10, sort: "-publishedAt") {
docs {
id
title
slug
excerpt
author {
name
}
}
totalDocs
totalPages
}
}
*/Fix 5: Custom Hooks and Validation
// collections/Orders.ts — hooks for business logic
export const Orders: CollectionConfig = {
slug: 'orders',
hooks: {
beforeValidate: [
({ data }) => {
// Auto-calculate total
if (data?.items) {
data.total = data.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity, 0
);
}
return data;
},
],
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'create') {
// Send order confirmation email
await sendOrderConfirmation(doc.email, doc);
// Update inventory
for (const item of doc.items) {
await req.payload.update({
collection: 'products',
id: item.product,
data: { stock: { decrement: item.quantity } },
});
}
}
},
],
},
fields: [
{ name: 'email', type: 'email', required: true },
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{ name: 'product', type: 'relationship', relationTo: 'products', required: true },
{ name: 'quantity', type: 'number', required: true, min: 1 },
{ name: 'price', type: 'number', required: true },
],
},
{ name: 'total', type: 'number', admin: { readOnly: true } },
{
name: 'status',
type: 'select',
defaultValue: 'pending',
options: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
},
],
};Fix 6: Custom Field Components
// fields/ColorPicker.tsx — custom admin UI field
'use client';
import { useField } from '@payloadcms/ui';
import type { TextFieldClientComponent } from 'payload';
const ColorPicker: TextFieldClientComponent = ({ path, field }) => {
const { value, setValue } = useField<string>({ path });
return (
<div>
<label>{field.label}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="color"
value={value || '#000000'}
onChange={(e) => setValue(e.target.value)}
/>
<input
type="text"
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder="#000000"
/>
</div>
</div>
);
};
export default ColorPicker;
// Use in a collection
{
name: 'brandColor',
type: 'text',
admin: {
components: {
Field: '/fields/ColorPicker',
},
},
}Still Not Working?
Admin panel is blank or shows “Cannot read properties of undefined” — the payload.config.ts has an error. Check that all imported collections exist and are valid. Also verify the db adapter is correctly configured and the database is reachable. Run npx payload generate:types to check for config errors.
403 on all API requests — access control functions return false or are missing. Add access: { read: () => true } to your collection to make it publicly readable. For authenticated access, check that the user is logged in: ({ req }) => !!req.user. Remember that access functions receive the request context, not just a boolean.
Local API returns empty results but admin shows data — check the where clause and depth parameter. depth: 0 returns relationship IDs instead of populated documents. Also verify you’re not hitting access control — the local API respects access rules by default. Pass overrideAccess: true for internal operations: payload.find({ collection: 'posts', overrideAccess: true }).
TypeScript types are out of date — run npx payload generate:types after changing collection configs. This regenerates payload-types.ts with up-to-date types for all collections, globals, and their fields.
Migrations fail on Postgres after switching adapters — moving from Mongoose to Drizzle (or vice versa) is not a config change. The schemas are stored differently, IDs change types, and there is no auto-migration between them. Export your data via payload export, switch adapters, run npx payload migrate:create on the fresh schema, and re-import. For Drizzle-specific migration errors, see Fix: Drizzle ORM Not Working.
Sharp module fails to load on serverless — Payload uses sharp for image resizing on the Media collection. The sharp binary is platform-specific; if your local machine builds for macOS and you deploy to Linux serverless, the binary mismatches. Add sharp to serverExternalPackages in next.config.mjs so Next.js does not bundle it, and ensure your platform installs the Linux binary. See Fix: Next.js Build Failed for adjacent build-output issues.
Uploads work locally but 404 in production — the local disk adapter is the default. On Vercel and other ephemeral runtimes, the filesystem resets per cold start, so uploaded files vanish. Configure the S3 plugin or Vercel Blob plugin and re-deploy. For the Cloudflare R2 setup that mirrors the S3 plugin config, see Fix: Cloudflare R2 Not Working.
For related CMS and backend issues, see Fix: Supabase Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation Errors
How to fix ts-rest issues — contract definition, type-safe client and server setup, Zod validation, Next.js App Router integration, error handling, and OpenAPI generation.
Fix: Auth.js (NextAuth) Not Working — Session Null, OAuth Callback Error, or CSRF Token Mismatch
How to fix Auth.js and NextAuth.js issues — OAuth provider setup, session handling in App Router and Pages Router, JWT vs database sessions, middleware protection, and credential provider configuration.