Custom Payload CMS Collections
A Collection in Payload is a content type with a REST API, GraphQL schema, and admin panel interface automatically generated from TypeScript configuration. Each collection is stored in a separate PostgreSQL table or MongoDB collection.
Collection Structure
// collections/Products.ts
import { CollectionConfig } from 'payload/types'
const Products: CollectionConfig = {
slug: 'products', // URL segment: /api/products
labels: {
singular: 'Product',
plural: 'Products',
},
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'price', 'category', 'inStock'],
group: 'Catalog',
},
// ...
}
Field Types
fields: [
// Text
{ name: 'name', type: 'text', required: true },
{ name: 'description', type: 'textarea' },
{ name: 'content', type: 'richText' },
// Numbers and dates
{ name: 'price', type: 'number', min: 0, required: true },
{ name: 'publishedAt', type: 'date' },
// Selection
{
name: 'status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
],
defaultValue: 'active',
},
// Media
{ name: 'image', type: 'upload', relationTo: 'media' },
// Relationships
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
hasMany: false,
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
// Array of objects
{
name: 'variants',
type: 'array',
fields: [
{ name: 'sku', type: 'text', required: true },
{ name: 'color', type: 'text' },
{ name: 'size', type: 'text' },
{ name: 'stock', type: 'number', defaultValue: 0 },
],
},
// Blocks (like Gutenberg)
{
name: 'sections',
type: 'blocks',
blocks: [TextBlock, ImageBlock, CTABlock],
},
// Field group
{
name: 'seo',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
},
]
Access Control
access: {
// Reading — public
read: () => true,
// Creation — authenticated users only
create: ({ req: { user } }) => Boolean(user),
// Update — owner or admin only
update: ({ req: { user }, id }) => {
if (!user) return false
if (user.role === 'admin') return true
return { author: { equals: user.id } } // filter condition
},
// Deletion — admin only
delete: ({ req: { user } }) => user?.role === 'admin',
},
Collection Hooks
hooks: {
beforeChange: [
async ({ data, req, operation }) => {
// Automatic slug generation
if (operation === 'create' && !data.slug) {
data.slug = data.name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '')
}
// Set author
if (operation === 'create' && req.user) {
data.author = req.user.id
}
return data
},
],
afterChange: [
async ({ doc, operation }) => {
// Invalidate Next.js cache
if (operation === 'update') {
await fetch(`/api/revalidate?path=/products/${doc.slug}`, {
method: 'POST',
})
}
},
],
afterDelete: [
async ({ doc }) => {
// Clean up related data
console.log(`Product ${doc.id} deleted`)
},
],
},
Versioning
versions: {
maxPerDoc: 20,
drafts: {
autosave: {
interval: 2000, // autosave every 2 seconds
},
},
},
Collection Queries
// On the server (Next.js Server Component)
import { getPayload } from 'payload'
import config from '@payload-config'
const payload = await getPayload({ config })
// Find published products in a category
const result = await payload.find({
collection: 'products',
where: {
and: [
{ status: { equals: 'active' } },
{ category: { equals: categoryId } },
{ price: { less_than: 10000 } },
],
},
sort: '-createdAt',
limit: 20,
page: 1,
depth: 2, // relationship population depth
})
const { docs, totalDocs, hasNextPage } = result
REST API (Auto-generated)
# List
GET /api/products?where[status][equals]=active&limit=20
# Single document
GET /api/products/:id
# Create (requires auth)
POST /api/products
Authorization: Bearer <token>
Content-Type: application/json
# Update
PATCH /api/products/:id
# Delete
DELETE /api/products/:id
Timeline
Configuring one collection with fields, access, and hooks — 2–4 hours. Full catalog (5–10 interconnected collections) — 2–4 days.







