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

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-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:

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:

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:

  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:

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 -> 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.

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.