Move to single .env file
This commit is contained in:
653
docs/payload-cms.md
Normal file
653
docs/payload-cms.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user