18 KiB
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-settingsglobal - 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 throughPAYLOAD_DB_URLsecret: env.PAYLOAD_SECRETenables Payload auth/session securitycollections: [Media, Pages]registers the current CMS collectionsglobals: [SiteSettings]registers shared settingsadmin.livePreviewenables live preview for thepagescollectiontypescript.outputFilewrites generated types toapps/next/payload-types.ts
Collections and globals
Current CMS entities:
pagesinapps/next/src/payload/collections/pages.ts- stores both the landing page and service pages
- uses
pageTypeto distinguishlandingvsservice - stores block layout in
layout - stores SEO fields in
seo - stores service-specific structured data in
structuredData
mediainapps/next/src/payload/collections/media.ts- image uploads used by blocks and SEO
site-settingsinapps/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:
- the route calls
getPageBySlug(...) - Payload fetches the matching
pagesdocument - the page metadata is generated from
seo/ fallback values - the page content is rendered through
LivePreviewPage LivePreviewPageuses Payload live preview to update content in the editor iframeRefreshRouteOnSaverefreshes 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
- subscribes to Payload live preview messages with
apps/next/src/components/payload/refresh-route-on-save.tsx- refreshes the current route after document saves/publishes
Important requirement:
src/proxy.tsandsrc/lib/proxy/ban-sus-ips.tsmust 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.tsapps/next/src/payload/seed/service-pages.tsapps/next/src/payload/seed/index.ts
Run the seed with:
cd apps/next
bun run seed
What it does:
- updates
site-settings - creates or updates the
homelanding page - creates or updates the
contactpage - 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_SECRETPAYLOAD_DB_URLNEXT_PUBLIC_SITE_URL
Why they matter:
PAYLOAD_SECRETsecures Payload sessions and server behaviorPAYLOAD_DB_URLconnects Payload to PostgresNEXT_PUBLIC_SITE_URLis 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.
- Open Payload admin at
/admin - Go to the
Pagescollection - Create a new document
- Set:
titleslugpageType = service
- Build the
layoutusing the existing blocks - Fill in
seo - Fill in
structuredData.serviceNameandstructuredData.serviceDescription - Save draft or publish
- 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
pagesdoc withpageType: '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
pagescollection with a new route, or - in a new Payload collection if the content model is materially different
If it fits pages:
- add or reuse blocks in the
layout - create the frontend route that fetches the document by slug
- generate metadata from the document
- render the layout with
LivePreviewPage - include
RefreshRouteOnSave
Example pattern:
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
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:
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:
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:
- export the schema block from
apps/next/src/payload/blocks/index.ts - add the block to
apps/next/src/payload/collections/pages.ts - add the renderer to
apps/next/src/components/payload/blocks/render-blocks.tsx - regenerate
apps/next/payload-types.ts
Type generation command:
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:
// 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:
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 ->
landingHeroorserviceHero - card rows ->
cardGrid - comparisons ->
tabularComparisonortwoColumnComparison - FAQ ->
faq - pricing ->
pricingCards
Step 3: Create new blocks if the page needs them
If the existing block library is not enough:
- add a schema block in
apps/next/src/payload/blocks/ - add a renderer in
apps/next/src/components/payload/blocks/ - 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.metaTitleor 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:
- fetch the page with
getPageBySlug - return
notFound()if it does not exist - generate metadata from the doc
- render
RefreshRouteOnSave - render JSON-LD with
next/scriptif needed - 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:
- create Payload collection and blocks
- add frontend readers and renderers
- seed the docs
- verify save/publish/live preview
- 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:
- model the content shape
- add or reuse blocks
- add renderers
- register blocks in the collection
- wire the route to Payload
- move metadata and structured data
- seed the content
- verify preview and publish
- 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.tsapps/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
useLivePreviewon 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.