Files
convex-monorepo-payload/docs/payload-cms.md
2026-03-27 16:43:22 -05:00

654 lines
18 KiB
Markdown

# Payload CMS In St Pete IT
This document explains how Payload CMS is integrated into the repo, how the landing and
service pages work, how to add new Payload-managed pages, and how to migrate existing
hardcoded pages into the current block-based setup.
## What Payload Is Responsible For
Payload currently manages the editable marketing content layer inside the Next.js app.
That means:
- the landing page at `/`
- the contact page at `/contact`
- the service pages at `/services/[slug]`
- shared marketing settings in the `site-settings` global
- the admin UI at `/admin`
- the REST API under `/api`
- live preview and route refresh when editors save or publish changes
Payload is not replacing Convex. Convex still handles the product backend: auth,
tickets, invoices, appointments, portal data, and admin operations. Payload only owns
the CMS side for page content.
## High-Level Architecture
### Core config
Payload is configured in `apps/next/src/payload.config.ts`.
Important pieces there:
- `postgresAdapter(...)` points Payload at Postgres through `PAYLOAD_DB_URL`
- `secret: env.PAYLOAD_SECRET` enables Payload auth/session security
- `collections: [Media, Pages]` registers the current CMS collections
- `globals: [SiteSettings]` registers shared settings
- `admin.livePreview` enables live preview for the `pages` collection
- `typescript.outputFile` writes generated types to `apps/next/payload-types.ts`
### Collections and globals
Current CMS entities:
- `pages` in `apps/next/src/payload/collections/pages.ts`
- stores both the landing page and service pages
- uses `pageType` to distinguish `landing` vs `service`
- stores block layout in `layout`
- stores SEO fields in `seo`
- stores service-specific structured data in `structuredData`
- `media` in `apps/next/src/payload/collections/media.ts`
- image uploads used by blocks and SEO
- `site-settings` in `apps/next/src/payload/globals/site-settings.ts`
- shared business info and service-page CTA settings
### Block system
Payload page content is built from reusable blocks.
Schema side:
- `apps/next/src/payload/blocks/*.ts`
- exported via `apps/next/src/payload/blocks/index.ts`
Render side:
- `apps/next/src/components/payload/blocks/*.tsx`
- selected by `apps/next/src/components/payload/blocks/render-blocks.tsx`
The rule is simple: every Payload block needs both parts.
- schema block: defines the fields editors can fill in
- renderer block: turns that block data into frontend UI
If one side is missing, the admin or the frontend will be incomplete.
### Frontend route flow
Landing page route:
- `apps/next/src/app/(frontend)/page.tsx`
Contact page route:
- `apps/next/src/app/(frontend)/contact/page.tsx`
Service page route:
- `apps/next/src/app/(frontend)/services/[slug]/page.tsx`
Shared server fetch helpers:
- `apps/next/src/lib/payload-helpers.tsx`
Behavior:
1. the route calls `getPageBySlug(...)`
2. Payload fetches the matching `pages` document
3. the page metadata is generated from `seo` / fallback values
4. the page content is rendered through `LivePreviewPage`
5. `LivePreviewPage` uses Payload live preview to update content in the editor iframe
6. `RefreshRouteOnSave` refreshes the route after save/publish so server-rendered data
stays in sync
### Live preview and publish behavior
There are two cooperating pieces:
- `apps/next/src/components/payload/live-preview-page.tsx`
- subscribes to Payload live preview messages with `useLivePreview`
- `apps/next/src/components/payload/refresh-route-on-save.tsx`
- refreshes the current route after document saves/publishes
Important requirement:
- `src/proxy.ts` and `src/lib/proxy/ban-sus-ips.ts` must not block valid Payload REST API
requests under `/api`
That was a real bug during setup: `PATCH` requests to publish pages were being blocked by
the suspicious-method middleware until `/api` writes were explicitly allowed.
## Seeded Content
Payload content is seeded from:
- `apps/next/src/payload/seed/landing-page.ts`
- `apps/next/src/payload/seed/service-pages.ts`
- `apps/next/src/payload/seed/index.ts`
Run the seed with:
```bash
cd apps/next
bun run seed
```
What it does:
- updates `site-settings`
- creates or updates the `home` landing page
- creates or updates the `contact` page
- creates or updates the default service pages
This matters because a fresh Payload database will otherwise return no page documents and
the frontend route will 404.
## Environment Variables
Payload depends on these env vars:
- `PAYLOAD_SECRET`
- `PAYLOAD_DB_URL`
- `NEXT_PUBLIC_SITE_URL`
Why they matter:
- `PAYLOAD_SECRET` secures Payload sessions and server behavior
- `PAYLOAD_DB_URL` connects Payload to Postgres
- `NEXT_PUBLIC_SITE_URL` is used by live preview to target the frontend correctly
If live preview points to the wrong place, or publish/save requests appear to work but the
preview never updates, this is one of the first things to check.
## How To Create A New Page Like The Current Ones
This section assumes you want another page managed by the existing `pages` collection.
### Option A: Create a new service page using the existing system
This is the simplest case.
1. Open Payload admin at `/admin`
2. Go to the `Pages` collection
3. Create a new document
4. Set:
- `title`
- `slug`
- `pageType = service`
5. Build the `layout` using the existing blocks
6. Fill in `seo`
7. Fill in `structuredData.serviceName` and `structuredData.serviceDescription`
8. Save draft or publish
9. Visit `/services/<slug>`
Why this works without adding a new route:
- the app already has a dynamic route at `apps/next/src/app/(frontend)/services/[slug]/page.tsx`
- any `pages` doc with `pageType: 'service'` and a matching slug can render there
If the page should exist by default in new environments, also add it to
`apps/next/src/payload/seed/service-pages.ts`.
### Option B: Create another landing-style page with the same block approach
If the page is not a service page, decide whether it belongs:
- in the existing `pages` collection with a new route, or
- in a new Payload collection if the content model is materially different
If it fits `pages`:
1. add or reuse blocks in the `layout`
2. create the frontend route that fetches the document by slug
3. generate metadata from the document
4. render the layout with `LivePreviewPage`
5. include `RefreshRouteOnSave`
Example pattern:
```tsx
const page = await getPageBySlug('some-slug');
if (!page) return notFound();
return (
<main>
<RefreshRouteOnSave serverURL={env.NEXT_PUBLIC_SITE_URL} />
<LivePreviewPage page={page} serverURL={env.NEXT_PUBLIC_SITE_URL} />
</main>
);
```
## Copy-Paste Route Template
Use this when creating a new non-service Payload-backed page route.
Adjust these parts:
- the slug passed to `getPageBySlug(...)`
- the metadata fallback values
- any JSON-LD you want to inject
```tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
import { LivePreviewPage } from '@/components/payload/live-preview-page';
import { RefreshRouteOnSave } from '@/components/payload/refresh-route-on-save';
import { env } from '@/env';
import { generatePageMetadata } from '@/lib/metadata';
import { getPageBySlug } from '@/lib/payload-helpers';
import { jsonLd } from '@/lib/structured-data';
export const generateMetadata = async (): Promise<Metadata> => {
const page = await getPageBySlug('some-slug');
if (!page) {
return generatePageMetadata({
title: 'Fallback Title',
description: 'Fallback description.',
path: '/some-path',
});
}
return generatePageMetadata({
title: page.seo?.metaTitle ?? page.title,
description: page.seo?.metaDescription ?? 'Fallback description.',
path: '/some-path',
keywords: page.seo?.keywords?.filter(Boolean) as string[] | undefined,
noIndex: page.seo?.noIndex ?? false,
});
};
const SomePage = async () => {
const page = await getPageBySlug('some-slug');
if (!page) return notFound();
return (
<main>
<RefreshRouteOnSave serverURL={env.NEXT_PUBLIC_SITE_URL} />
<Script
id='ld-json-some-page'
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: jsonLd({ '@context': 'https://schema.org' }),
}}
/>
<LivePreviewPage page={page} serverURL={env.NEXT_PUBLIC_SITE_URL} />
</main>
);
};
export default SomePage;
```
If the page does not need JSON-LD, remove the `Script` import and block.
For service pages, do not create a separate static route unless there is a strong reason.
Prefer the existing dynamic route at `apps/next/src/app/(frontend)/services/[slug]/page.tsx`.
## Copy-Paste Block Template
Use this when you need a new reusable Payload block for a page migration.
Schema file example:
```ts
import type { Block } from 'payload';
export const ExampleBlock: Block = {
slug: 'exampleBlock',
labels: {
singular: 'Example Block',
plural: 'Example Blocks',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
{
name: 'items',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
],
},
],
};
```
Renderer example:
```tsx
import type { Page } from '../../../../payload-types';
type ExampleBlockData = Extract<
NonNullable<Page['layout']>[number],
{ blockType: 'exampleBlock' }
>;
export const ExampleBlockRenderer = ({
block,
}: {
block: ExampleBlockData;
}) => {
return (
<section>
<h2>{block.title}</h2>
{block.description && <p>{block.description}</p>}
<ul>
{block.items?.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
</section>
);
};
```
Registration checklist:
1. export the schema block from `apps/next/src/payload/blocks/index.ts`
2. add the block to `apps/next/src/payload/collections/pages.ts`
3. add the renderer to `apps/next/src/components/payload/blocks/render-blocks.tsx`
4. regenerate `apps/next/payload-types.ts`
Type generation command:
```bash
cd apps/next
bun with-env bunx payload generate:types --config src/payload.config.ts
```
## Copy-Paste Seed Template
Use this when a new Payload-backed page should exist automatically in local/dev or fresh
environments.
Seed document example:
```ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const examplePageSeed: Record<string, any> = {
title: 'Example Page',
slug: 'example',
pageType: 'standard',
layout: [
{
blockType: 'exampleBlock',
title: 'Hello from Payload',
},
],
seo: {
metaTitle: 'Example Page',
metaDescription: 'Example description.',
noIndex: false,
},
_status: 'published',
};
```
Seed upsert example:
```ts
const existing = await payload.find({
collection: 'pages',
where: { slug: { equals: 'example' } },
limit: 1,
});
if (existing.docs.length > 0) {
await payload.update({
collection: 'pages',
id: existing.docs[0]!.id,
draft: false,
data: examplePageSeed as never,
});
} else {
await payload.create({
collection: 'pages',
draft: false,
data: examplePageSeed as never,
});
}
```
The important part is not the exact code shape. The important part is that a new
Payload-backed route should not depend on manually created admin content if the page is
expected to exist in every environment.
## How To Add A New Block
If editors need a new section type, add a new block.
### 1. Create the Payload schema block
Add a file under:
- `apps/next/src/payload/blocks/<block-name>.ts`
This file defines the editor-facing fields.
### 2. Export it from the block barrel
Update:
- `apps/next/src/payload/blocks/index.ts`
### 3. Register it in the `pages` collection
Update:
- `apps/next/src/payload/collections/pages.ts`
Add the block to the `layout.blocks` array.
### 4. Build the frontend renderer
Add:
- `apps/next/src/components/payload/blocks/<block-name>.tsx`
### 5. Register the renderer
Update:
- `apps/next/src/components/payload/blocks/render-blocks.tsx`
### 6. Seed it if needed
If the new block is used in default pages, update the seed files too.
## How To Migrate An Existing Hardcoded Page To Payload CMS
This is the full workflow for converting an old React page into the current CMS model.
### Step 1: Audit the existing page
Identify all of these before touching code:
- route path
- metadata and title
- structured data / JSON-LD
- hero section
- repeated content sections
- CTA areas
- any shared content that should become global settings
Do not start by blindly copying JSX into one giant rich text field. The current system is
block-based for a reason.
### Step 2: Break the page into reusable sections
Ask which parts are:
- already represented by existing blocks
- better suited as a new reusable block
- global content instead of page-local content
Examples:
- hero section -> `landingHero` or `serviceHero`
- card rows -> `cardGrid`
- comparisons -> `tabularComparison` or `twoColumnComparison`
- FAQ -> `faq`
- pricing -> `pricingCards`
### Step 3: Create new blocks if the page needs them
If the existing block library is not enough:
1. add a schema block in `apps/next/src/payload/blocks/`
2. add a renderer in `apps/next/src/components/payload/blocks/`
3. register both sides
Keep blocks composable and editor-friendly. Prefer a few clearly named fields over one big
opaque content blob.
### Step 4: Move SEO into document fields
Map metadata into the Payload document:
- title -> `seo.metaTitle` or route fallback
- meta description -> `seo.metaDescription`
- keywords -> `seo.keywords`
- noindex -> `seo.noIndex`
- service-specific schema -> `structuredData`
If the page uses JSON-LD, keep the generation logic in the route and read values from the
Payload document.
### Step 5: Replace the hardcoded route with a Payload-backed route
The route should:
1. fetch the page with `getPageBySlug`
2. return `notFound()` if it does not exist
3. generate metadata from the doc
4. render `RefreshRouteOnSave`
5. render JSON-LD with `next/script` if needed
6. render `LivePreviewPage`
For service pages, prefer reusing the dynamic service route instead of creating many
one-off route files.
### Step 6: Seed the migrated page
If the page should exist locally or in fresh environments, add it to the seed system.
This prevents the common failure mode where the route now expects Payload content but the
database has no corresponding document yet.
### Step 7: Verify the full editor flow
After migration, verify all of these:
- the route loads without 404
- the document appears in `/admin`
- draft save works
- publish works
- live preview updates while editing
- route refreshes after save/publish
- dark/light preview styling still looks correct
- seeded content loads on a fresh database
### Step 8: Delete the old hardcoded page only after verification
Do not remove the old page implementation until the Payload-backed route is proven working.
For the service-page migration in this repo, the safe order was:
1. create Payload collection and blocks
2. add frontend readers and renderers
3. seed the docs
4. verify save/publish/live preview
5. remove the old hardcoded service page files
That order avoids breaking the site in the middle of the migration.
## Recommended Checklist For Future Migrations
Use this exact order:
1. model the content shape
2. add or reuse blocks
3. add renderers
4. register blocks in the collection
5. wire the route to Payload
6. move metadata and structured data
7. seed the content
8. verify preview and publish
9. remove old hardcoded components/routes
## Common Failure Modes
### 1. Page 404s after migration
Usually means the Payload document does not exist yet.
Check:
- the route slug
- the document slug
- whether the seed ran
### 2. Publish/save shows `Not Found`
Usually means middleware or proxy rules are intercepting Payload API writes before they
reach Payload.
Check:
- `apps/next/src/proxy.ts`
- `apps/next/src/lib/proxy/ban-sus-ips.ts`
### 3. Live preview frame loads but does not update
Usually means one of these is wrong:
- `NEXT_PUBLIC_SITE_URL`
- Payload live preview config URL
- missing `useLivePreview` on the frontend page
- missing `RefreshRouteOnSave`
### 4. Dark mode preview looks wrong
Usually means the theme classes or body-level theme tokens are not being applied inside the
preview iframe.
Check the frontend layout and ensure `bg-background` / `text-foreground` are applied at the
body level.
## Important Files At A Glance
- config: `apps/next/src/payload.config.ts`
- page collection: `apps/next/src/payload/collections/pages.ts`
- media collection: `apps/next/src/payload/collections/media.ts`
- global settings: `apps/next/src/payload/globals/site-settings.ts`
- block schemas: `apps/next/src/payload/blocks/*`
- block renderers: `apps/next/src/components/payload/blocks/*`
- route helpers: `apps/next/src/lib/payload-helpers.tsx`
- landing route: `apps/next/src/app/(frontend)/page.tsx`
- contact route: `apps/next/src/app/(frontend)/contact/page.tsx`
- service route: `apps/next/src/app/(frontend)/services/[slug]/page.tsx`
- live preview client bridge: `apps/next/src/components/payload/live-preview-page.tsx`
- save/publish refresh bridge: `apps/next/src/components/payload/refresh-route-on-save.tsx`
- seed entrypoint: `apps/next/src/payload/seed/index.ts`
## Practical Rule Of Thumb
If the change is about editable marketing content, reach for Payload first.
If the change is about business logic, authenticated workflows, tickets, invoices,
appointments, or portal/admin operations, it probably belongs in Convex instead.