Every field type, access control pattern, and hook in Payload CMS 3 with real-world examples and TypeScript types.
March 31, 2026

Payload CMS 3.0 collections are the building blocks of your data model. Each collection becomes a database table, a REST endpoint, a GraphQL type, and an admin UI panel — all from a single configuration object. This reference covers the field types and patterns you'll use most.
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts', // URL segment, DB table name, API endpoint
admin: {
useAsTitle: 'title', // which field shows in the admin list
defaultColumns: ['title', 'status', 'publishedAt'],
},
access: {
read: () => true, // public read
create: isAdmin, // admin-only write
update: isAdmin,
delete: isAdmin,
},
hooks: {
beforeChange: [],
afterChange: [],
},
fields: [],
}
text{
name: 'title',
type: 'text',
required: true,
unique: true, // adds DB unique constraint
index: true, // adds DB index
minLength: 3,
maxLength: 100,
admin: {
description: 'Display name shown in admin',
placeholder: 'Enter title...',
},
}
textarea{
name: 'description',
type: 'textarea',
required: true,
maxLength: 160, // useful for SEO meta descriptions
}
richTextimport { lexicalEditor } from '@payloadcms/richtext-lexical'
{
name: 'content',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ rootFeatures }) => [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3'] }),
BlocksFeature({ blocks: [Callout, CodeBlock] }),
],
}),
}
select{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
}
relationshipThe most important field type for multi-collection schemas:
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants', // slug of another collection
required: true,
index: true, // ALWAYS index relationship fields used in queries
hasMany: false,
}
// Many-to-many (hasMany: true)
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
}
// Polymorphic (multiple collections)
{
name: 'relatedContent',
type: 'relationship',
relationTo: ['posts', 'pages'],
hasMany: true,
}
Always index relationship fields you query by. Without an index, fetching all posts for a tenant scans every row. With an index, it's a fast lookup.
upload{
name: 'featuredImage',
type: 'upload',
relationTo: 'media', // must be a collection with `upload: true`
}
The Media collection that backs this:
export const Media: CollectionConfig = {
slug: 'media',
upload: {
staticDir: 'media',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
imageSizes: [
{ name: 'thumbnail', width: 400 },
{ name: 'card', width: 800 },
{ name: 'hero', width: 1600 },
],
},
fields: [
{ name: 'alt', type: 'text', required: true },
],
}
arrayFor repeatable groups of fields:
{
name: 'tags',
type: 'array',
fields: [
{ name: 'tag', type: 'text', required: true },
],
minRows: 0,
maxRows: 10,
}
// Usage in a post creation script:
tags: [{ tag: 'TypeScript' }, { tag: 'Next.js' }]
groupFor non-repeating nested fields (no separate DB table):
{
name: 'theme',
type: 'group',
fields: [
{ name: 'primary', type: 'text' },
{ name: 'secondary', type: 'text' },
{ name: 'accent', type: 'text' },
],
}
// In code: tenant.theme.primary
date{
name: 'publishedAt',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
}
codeFor storing code strings with syntax highlighting in the admin:
{
name: 'mdxContent',
type: 'code',
admin: {
language: 'mdx',
description: 'MDX content for frontend rendering',
},
}
Access functions receive the request and return a boolean (or a query constraint for filtered reads):
const isAdmin = ({ req }: { req: PayloadRequest }) =>
Boolean(req.user)
// Filtered read — public can only see published posts
const readPublished = ({ req }: { req: PayloadRequest }) => {
if (req.user) return true // admins see everything
return {
status: { equals: 'published' },
}
}
export const Posts: CollectionConfig = {
access: {
read: readPublished,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
}
Run code before or after collection operations:
{
hooks: {
beforeChange: [
({ data, operation }) => {
// Auto-set publishedAt when status changes to published
if (operation === 'update' && data.status === 'published' && !data.publishedAt) {
return { ...data, publishedAt: new Date().toISOString() }
}
return data
},
],
afterChange: [
async ({ doc }) => {
// Trigger revalidation of the Next.js frontend cache
if (doc.status === 'published') {
await revalidatePost(doc.slug)
}
},
],
},
}
After any schema change, regenerate TypeScript types:
cd apps/cms
pnpm generate:types
This updates src/payload-types.ts. Import Post, Tenant, Media etc. from there for fully-typed access to your collection shapes.
group is for nested fields (no DB table); array is for repeatable rows (separate DB table)true/false for full access or a query constraint for filtered readsbeforeChange hooks can modify the document before it's saved; afterChange hooks are for side effectspnpm generate:types after schema changes to keep TypeScript types in sync