654 lines
18 KiB
Markdown
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.
|