Payload CMS Installation and Configuration

Payload CMS is a TypeScript-first headless CMS built on Node.js that generates a full REST and GraphQL API from your schema definitions. With code-based configuration, flexible access control, hooks, and native Next.js integration, Payload gives developers complete control over their content infrastructure on Linux.

Prerequisites

  • Node.js 18.20+ or 20.9+
  • MongoDB 5.0+ or PostgreSQL 11+ (with Drizzle adapter)
  • npm or yarn
  • A Linux VPS with at least 1 GB RAM

Project Setup

# Create a new Payload project (interactive setup)
npx create-payload-app@latest my-cms
cd my-cms

# Or install into an existing Next.js project
npx create-payload-app@latest --no-next-app

# Choose from templates:
# - blank (minimal setup)
# - website (blog with Pages, Posts, Media, Users)
# - ecommerce (products, orders, customers)

# Install dependencies
npm install

# Set up environment variables
cp .env.example .env

Configure your .env file:

# .env
DATABASE_URI=mongodb://localhost:27017/my-cms
# Or for PostgreSQL:
# DATABASE_URI=postgresql://user:password@localhost:5432/mycms

PAYLOAD_SECRET=your-32-character-secret-key
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000

# Optional S3 storage
S3_BUCKET=my-bucket
S3_ACCESS_KEY_ID=your-key
S3_SECRET_ACCESS_KEY=your-secret
S3_REGION=us-east-1
# Start the development server
npm run dev

# Access admin panel at http://localhost:3000/admin
# Create your first admin user on first visit

Collection Configuration

Collections are defined in TypeScript configuration files:

// src/collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'author', 'updatedAt'],
  },
  versions: {
    drafts: true,
    maxPerDoc: 10,
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      unique: true,
      admin: {
        position: 'sidebar',
      },
      hooks: {
        beforeValidate: [
          ({ value, data }) =>
            value || data?.title?.toLowerCase().replace(/\s+/g, '-'),
        ],
      },
    },
    {
      name: 'content',
      type: 'richText',
    },
    {
      name: 'status',
      type: 'select',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      defaultValue: 'draft',
      admin: { position: 'sidebar' },
    },
    {
      name: 'author',
      type: 'relationship',
      relationTo: 'users',
      hasMany: false,
    },
    {
      name: 'featuredImage',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'tags',
      type: 'array',
      fields: [
        { name: 'tag', type: 'text' },
      ],
    },
  ],
  timestamps: true,
}

Register collections in your Payload config:

// payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { slateEditor } from '@payloadcms/richtext-slate'
import { Posts } from './collections/Posts'
import { Media } from './collections/Media'
import { Users } from './collections/Users'

export default buildConfig({
  admin: {
    user: Users.slug,
  },
  collections: [Posts, Media, Users],
  editor: slateEditor({}),
  secret: process.env.PAYLOAD_SECRET || '',
  typescript: {
    outputFile: path.resolve(__dirname, 'payload-types.ts'),
  },
  db: mongooseAdapter({
    url: process.env.DATABASE_URI || '',
  }),
})

Access Control

Payload's access control functions return true, false, or a query constraint:

// src/collections/Posts.ts
import type { Access } from 'payload'

const isAdmin: Access = ({ req: { user } }) => {
  return user?.role === 'admin'
}

const isAuthorOrAdmin: Access = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true

  // Return a query constraint to filter by author
  return {
    author: {
      equals: user.id,
    },
  }
}

const isPublished: Access = () => ({
  status: { equals: 'published' },
})

export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    read: isPublished,
    create: ({ req: { user } }) => Boolean(user),
    update: isAuthorOrAdmin,
    delete: isAdmin,
  },
  // ... fields
}

Global Access Control

// payload.config.ts
export default buildConfig({
  // ...
  globals: [
    {
      slug: 'site-settings',
      access: {
        read: () => true,           // Public read
        update: isAdmin,            // Admin only write
      },
      fields: [
        { name: 'siteName', type: 'text' },
        { name: 'siteDescription', type: 'textarea' },
      ],
    },
  ],
})

Hooks

Hooks run code at specific points in the data lifecycle:

// src/collections/Posts.ts
export const Posts: CollectionConfig = {
  slug: 'posts',
  hooks: {
    // Before saving to database
    beforeChange: [
      ({ data, req, operation }) => {
        if (operation === 'create') {
          data.author = req.user?.id
          data.createdBy = req.user?.email
        }
        return data
      },
    ],

    // After saving to database
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation === 'create' || doc.status === 'published') {
          // Invalidate cache or trigger webhook
          await fetch('https://myapp.com/api/revalidate', {
            method: 'POST',
            headers: { 'x-secret': process.env.REVALIDATION_SECRET || '' },
            body: JSON.stringify({ slug: doc.slug }),
          })
        }
      },
    ],

    // After reading from database
    afterRead: [
      ({ doc }) => {
        // Add computed fields
        doc.readingTime = Math.ceil(doc.content?.length / 1000)
        return doc
      },
    ],

    // Before deleting
    beforeDelete: [
      async ({ id, req }) => {
        req.payload.logger.info(`Deleting post ${id}`)
      },
    ],
  },
  fields: [/* ... */],
}

REST and GraphQL APIs

Payload auto-generates complete APIs from your collection config:

# REST API endpoints (auto-generated)
GET    /api/posts              # List posts
POST   /api/posts              # Create post
GET    /api/posts/:id          # Get post by ID
PATCH  /api/posts/:id          # Update post
DELETE /api/posts/:id          # Delete post

# Query with filters
GET /api/posts?where[status][equals]=published&limit=10&page=2

# Query with depth (populate relationships)
GET /api/posts?depth=2

# Example: fetch published posts
curl "http://localhost:3000/api/posts?where[status][equals]=published&limit=5" \
  -H "Authorization: JWT your-token"

GraphQL

# GraphQL endpoint
POST /api/graphql

# Example query
curl -X POST "http://localhost:3000/api/graphql" \
  -H "Content-Type: application/json" \
  -H "Authorization: JWT your-token" \
  -d '{
    "query": "query { Posts(where: {status: {equals: published}}) { docs { id title slug author { email } } totalDocs } }"
  }'

Authentication

# Login and get JWT
curl -X POST "http://localhost:3000/api/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "password123"}'

# Use the returned token
curl "http://localhost:3000/api/posts" \
  -H "Authorization: JWT eyJhbGci..."

Next.js Integration

Payload 3.x runs directly inside Next.js:

// src/app/(payload)/admin/[[...segments]]/page.tsx
// Admin panel is served directly by Next.js - no separate server needed

// Fetch content server-side
import { getPayload } from 'payload'
import configPromise from '@payload-config'

// In a Next.js page or API route:
const payload = await getPayload({ config: configPromise })

const posts = await payload.find({
  collection: 'posts',
  where: {
    status: { equals: 'published' },
  },
  limit: 10,
  depth: 2,
})

// For static generation with draft preview
export async function generateStaticParams() {
  const payload = await getPayload({ config: configPromise })
  const posts = await payload.find({ collection: 'posts', limit: 1000 })
  return posts.docs.map(({ slug }) => ({ slug }))
}

Production Deployment

# Build the Next.js + Payload application
npm run build

# Start production server
npm run start

# Or using PM2 for process management
npm install -g pm2
pm2 start npm --name "payload-cms" -- start
pm2 save
pm2 startup

# Configure Nginx reverse proxy
sudo tee /etc/nginx/sites-available/payload << 'EOF'
server {
    server_name cms.yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        client_max_body_size 50M;
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/payload /etc/nginx/sites-enabled/
sudo certbot --nginx -d cms.yourdomain.com

Troubleshooting

TypeScript compilation errors:

# Regenerate Payload types
npm run generate:types

# Check TypeScript version compatibility
npx tsc --version
# Payload requires TypeScript 5.x

Database connection fails:

# Test MongoDB connection
mongosh "mongodb://localhost:27017/my-cms"

# Test PostgreSQL connection (if using Drizzle)
psql "postgresql://user:password@localhost:5432/mycms"

# Check DATABASE_URI in .env
cat .env | grep DATABASE_URI

Admin panel redirect loop:

# Clear browser cookies/local storage for localhost:3000
# Check PAYLOAD_SECRET is set and consistent
# Verify NEXT_PUBLIC_SERVER_URL matches actual URL

Hooks not firing:

# Hooks must be in the collection config file
# Check for TypeScript import errors
npm run dev 2>&1 | grep -i error

Conclusion

Payload CMS offers a code-first approach that gives developers full TypeScript type safety, version control for their content model, and flexible extensibility through hooks and access control functions. Its direct Next.js integration eliminates the need for a separate API server, and the auto-generated REST and GraphQL APIs make it straightforward to serve content to any frontend or mobile application.