Skip to content

Collection Factories

posts and pages used to be ~85% identical, and the CLI templates were a third copy of the same config. Three factories now own those defaults, so a collection file contains only what is actually specific to it.

Factory Import from Use for
publicCollection() @/lib/payload/collections/public-collection collections rendered on a public route
privateCollection() @/lib/payload/collections/private-collection collections managed in the admin panel only
siteGlobal() @/lib/payload/globals/site-global site-wide globals
src/collections/posts.ts
import { image } from '@/blocks/image/config';
import { text } from '@/blocks/text/config';
import { publicCollection } from '@/lib/payload/collections/public-collection';
import type { Post } from '@/payload-types';
export const posts = publicCollection<Post>({
slug: 'posts',
contentFields: [{ name: 'blocks', type: 'blocks', blocks: [image, text] }],
});

A public collection’s slug is typed against the registry in src/lib/url/slug.ts, so forgetting to register a route is a compile error rather than a runtime SlugPathNotFound.

publicCollection privateCollection siteGlobal
title field ✔ ✔
slug field ✔
featuredImage field ✔
Content tab ✔ ✔
excerpt field ✔
SEO tab + meta auto-generation ✔
Admin preview ✔
Path revalidation on save/delete ✔
Full layout revalidation on save ✔
Drafts, scheduled publish, trash ✔ ✔
Role-based access ✔ ✔ ✔
hideAPIURL ✔ ✔

Fields go in through named slots, never by passing a fields array:

Slot Where it lands
sidebarFields root level, before featuredImage, defaulted to the sidebar
rootFields root level, after featuredImage, left untouched
contentFields inside the Content tab, before excerpt
tabs extra tabs — see below

An explicit admin.position on a sidebarFields entry is respected, so a sidebar field can opt back into the main column.

Each factory assembles a single tabs field. The tabs slot adds your own tabs to it, so they share one tab bar with the built-in ones instead of rendering a second row:

export const posts = publicCollection<Post>({
slug: 'posts',
contentFields: [{ name: 'blocks', type: 'blocks', blocks: [image, text] }],
tabs: [
{
label: 'Settings',
fields: [{ name: 'featured', type: 'checkbox' }],
},
{
label: 'Relations',
name: 'related',
fields: [
{ name: 'posts', type: 'relationship', relationTo: 'posts', hasMany: true },
],
},
],
});

Where they land per factory:

Factory Position
publicCollection after the Content tab, before the SEO tab
privateCollection after the Content tab
siteGlobal they are the global’s tabs — there is no built-in one

disable: { seo: true } drops the SEO tab and leaves your tabs in place.

Three separate mechanisms, so nothing is ever lost by accident:

Want to Use
add a hook hooks — appended to the built-ins
remove a built-in disable
change any other config key overrides

overrides is typed as Omit<Partial<Config>, 'slug' | 'fields' | 'hooks'>. Passing fields or hooks there is a compile error — a plain spread would silently drop the SEO and revalidation behaviour the factory promises.

// appends → [defaultRevalidate, mine]
publicCollection({ slug: 'posts', hooks: { afterChange: [mine] } });
// replaces → [mine]
publicCollection({
slug: 'posts',
disable: { revalidate: true },
hooks: { afterChange: [mine] },
});
// merges into the default admin config, keeping useAsTitle/group/preview
publicCollection({
slug: 'pages',
overrides: { admin: { hideAPIURL: true } },
});

disable.seo removes the SEO tab and its beforeChange hook together, so the two cannot drift apart.

Built-ins always run before your hooks. To run first, disable the built-in and re-add both in your own order.

Every piece a factory assembles is exported on its own — titleField, featuredImageField, seoTab, draftVersions, collectionAccess, revalidateHooks, globalAccess, mergeHooks and the rest. A collection that outgrows its factory can drop to a plain CollectionConfig and still reuse them rather than copying the defaults back in. See the Collections guide for what a hand-written config looks like.