8.6 KiB
@repo/cms-core -- ALL Payload CMS Configuration
Purpose
This package contains all Payload CMS configuration: payload.config.ts, collections, globals, hooks, and access control. The apps/cms application is a thin shell that imports from this package -- it contains no CMS logic of its own.
Hard Rules
- ALL collections, globals, hooks, and access patterns live here, never in
apps/cms - Can import from
@repo/core/application(for use case delegation in hooks) - NEVER import from
@repo/core/infrastructure - NEVER import from any
apps/*package - Keep hooks thin (5-10 lines max) -- delegate to use cases for business logic
File Structure
packages/cms-core/
src/
payload.config.ts # Main Payload config (db, editor, collections, globals)
collections/
users/
index.ts # Users CollectionConfig (auth: true)
articles/
index.ts # Articles CollectionConfig (versions, hooks)
fields.ts # Article field definitions
hooks/
before-change.ts # Auto-generate slug from title
media/
index.ts # Media CollectionConfig (upload)
globals/
site-settings.ts # SiteSettings GlobalConfig
index.ts # Package entry: exports all configs
package.json
AGENTS.md
Existing Collections
Users (auth collection)
- Slug:
users - Auth:
true(provides login, password hashing, session management) - Fields:
displayName(text),role(select: admin/editor/author, default: author) - Admin title: email
Articles (content with versioning)
- Slug:
articles - Versions:
{ drafts: true }-- enables draft/published workflow - Hooks:
beforeChange: [autoGenerateSlug]-- generates slug from title if slug is empty - Fields: title (text, required), slug (text, unique, sidebar), content (richText), status (select: draft/published, default: draft), author (relationship to users, required), featuredImage (upload to media), publishedAt (date)
- Admin title: title, default columns: title, status, author, updatedAt
Media (file uploads)
- Slug:
media - Upload: Accepts
image/*andapplication/pdf - Fields:
alt(text, required) - Admin title: filename
Existing Globals
SiteSettings
- Slug:
site-settings - Admin group: Settings
- Fields:
siteName(text, required, default: "My App"),siteDescription(textarea)
Hook Rules
| Category | Where it lives | Description | Examples |
|---|---|---|---|
| CMS-operational | Stays in the hook file | Logic tied to CMS data shaping, not business rules | Slug generation, image resizing, setting default values, formatting fields |
| Business logic | Delegated to @repo/core use case |
Logic that enforces a business rule or triggers side effects | Sending notifications, validating against external data, cross-domain updates |
DO
- Keep hooks to 5-10 lines max
- Import use cases from
@repo/core/application(application layer only) - Map Payload hook arguments (
data,operation,req) to use case input types - Return
datafrombeforeChange/beforeValidatehooks
DON'T
- Import from
@repo/core/infrastructure-- violates Clean Architecture - Put business validation logic directly in hooks
- Call external services (email, analytics, APIs) directly from hooks
- Duplicate logic that already exists in a use case
- Access
req.payloadfor cross-collection operations (delegate to use case instead)
Rule of thumb: If deleting the hook would break a business requirement, the logic must live in a use case in @repo/core.
Recipe: Adding a New Collection
This example adds a Tags collection.
Step 1: Create the collection folder and fields
Create src/collections/tags/fields.ts:
import type { Field } from "payload";
export const tagFields: Field[] = [
{
name: "name",
type: "text",
required: true,
unique: true,
maxLength: 100,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from name if left empty",
},
},
{
name: "description",
type: "textarea",
},
];
Step 2: Create the CollectionConfig
Create src/collections/tags/index.ts:
import type { CollectionConfig } from "payload";
import { tagFields } from "./fields";
import { autoGenerateSlug } from "./hooks/before-change";
export const Tags: CollectionConfig = {
slug: "tags",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "slug", "updatedAt"],
},
hooks: {
beforeChange: [autoGenerateSlug],
},
fields: tagFields,
};
Step 3: Add a CMS-operational hook (slug generation)
Create src/collections/tags/hooks/before-change.ts:
import type { CollectionBeforeChangeHook } from "payload";
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
data,
operation,
}) => {
if (operation === "create" || operation === "update") {
if (data && data.name && !data.slug) {
data.slug = generateSlug(data.name);
}
}
return data;
};
Step 4: (Optional) Add a business-logic hook delegating to a use case
Create src/collections/tags/hooks/after-change.ts:
import type { CollectionAfterChangeHook } from "payload";
import { syncTagToSearchIndex } from "@repo/core/application";
export const syncTagAfterChange: CollectionAfterChangeHook = async ({
doc,
operation,
}) => {
// Delegate to use case -- this hook is just a thin bridge
await syncTagToSearchIndex({
id: doc.id,
name: doc.name,
slug: doc.slug,
operation,
});
return doc;
};
Step 5: (Optional) Add access control
Create src/collections/tags/access/is-admin.ts:
import type { Access } from "payload";
export const isAdmin: Access = ({ req: { user } }) => {
return user?.role === "admin";
};
Then reference it in the CollectionConfig:
export const Tags: CollectionConfig = {
slug: "tags",
access: {
create: isAdmin,
update: isAdmin,
delete: isAdmin,
// read is open by default
},
// ...rest
};
Step 6: Register in payload.config.ts
Edit src/payload.config.ts:
import { Tags } from "./collections/tags";
export default buildConfig({
collections: [Users, Articles, Media, Tags], // <-- add Tags
// ...rest
});
Step 7: Export from package entry
Edit src/index.ts:
export { Tags } from "./collections/tags"; // <-- add export
Recipe: Adding a New Global
This example adds a Navigation global.
Step 1: Create the GlobalConfig
Create src/globals/navigation.ts:
import type { GlobalConfig } from "payload";
export const Navigation: GlobalConfig = {
slug: "navigation",
admin: {
group: "Settings",
},
fields: [
{
name: "mainMenu",
type: "array",
fields: [
{
name: "label",
type: "text",
required: true,
},
{
name: "url",
type: "text",
required: true,
},
],
},
],
};
Step 2: Register in payload.config.ts
import { Navigation } from "./globals/navigation";
export default buildConfig({
globals: [SiteSettings, Navigation], // <-- add Navigation
// ...rest
});
Step 3: Export from package entry
export { Navigation } from "./globals/navigation"; // <-- add export
Payload Config Overview
The payload.config.ts uses:
- Database:
@payloadcms/db-postgres(PostgreSQL viaDATABASE_URLenv var) - Editor:
@payloadcms/richtext-lexical(Lexical rich text editor) - Secret:
PAYLOAD_SECRETenv var (required for production) - TypeScript output: Generates
payload-types.tsin this package'ssrc/directory
Dependencies
| Dependency | Purpose |
|---|---|
payload |
Payload CMS core |
@payloadcms/db-postgres |
PostgreSQL database adapter |
@payloadcms/richtext-lexical |
Lexical rich text editor |
Cross-References
- CMS app shell:
apps/cms/-- seeapps/cms/AGENTS.md - Use cases for hooks:
packages/core/src/application/use-cases/-- seepackages/core/AGENTS.md - CMS client for querying:
packages/cms-client/-- seepackages/cms-client/AGENTS.md