Fix: Payload CMS Not Working — Collections Not Loading, Auth Failing, or Admin Panel Blank
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. It runs inside your Next.js application rather than as a separate service:
- Payload is configured through a TypeScript config —
payload.config.tsdefines collections, globals, access control, and hooks. A misconfigured or missing config causes the admin panel to fail on load. - Access control is deny-by-default — without explicit
accessfunctions, collections are locked down. Theread,create,update, anddeleteoperations each need their own access function. Returningfalseor not defining access blocks the operation. - Payload runs inside Next.js — since Payload 3.0, it’s a Next.js plugin. The local API (
payload.find(),payload.create()) is available in Server Components, API routes, and Server Actions. Client components can’t use the local API directly. - Database adapters must be installed separately — Payload supports MongoDB (
@payloadcms/db-mongodb) and Postgres (@payloadcms/db-postgres). Without the correct adapter and a running database, Payload can’t start.
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.
For related CMS and backend issues, see Fix: Supabase Not Working and Fix: Next.js App Router 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.