Skip to content

Fix: Payload CMS Not Working — Collections Not Loading, Auth Failing, or Admin Panel Blank

FixDevs ·

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 Forbidden

Or 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 API

Or the database connection fails on startup:

Error: connect ECONNREFUSED 127.0.0.1:27017

Why 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 configpayload.config.ts defines 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 access functions, collections are locked down. The read, create, update, and delete operations each need their own access function. Returning false or 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles