Update all docs & md files
This commit is contained in:
@@ -1,25 +1,24 @@
|
||||
# Payload CMS In St Pete IT
|
||||
# Payload CMS In `convex-monorepo`
|
||||
|
||||
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.
|
||||
This document explains how Payload CMS is integrated into this template, what it manages
|
||||
today, and how to extend it safely when you want more editable marketing content.
|
||||
|
||||
## What Payload Is Responsible For
|
||||
## Current Scope
|
||||
|
||||
Payload currently manages the editable marketing content layer inside the Next.js app.
|
||||
That means:
|
||||
Payload currently powers the editable marketing layer inside the Next.js app.
|
||||
|
||||
Today 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
|
||||
- the GraphQL API under `/api/graphql`
|
||||
- live preview for the landing page
|
||||
|
||||
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.
|
||||
Payload is not replacing Convex.
|
||||
|
||||
- Convex still handles auth, backend logic, realtime data, files, and app workflows
|
||||
- Payload owns marketing content editing inside the Next app
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
@@ -29,118 +28,109 @@ 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`
|
||||
- `postgresAdapter(...)` connects Payload to Postgres via `PAYLOAD_DB_URL`
|
||||
- `secret: env.PAYLOAD_SECRET` secures Payload
|
||||
- `collections: [Users]` currently registers only the Payload admin user collection
|
||||
- `globals: [LandingPage]` registers the editable landing-page global
|
||||
- `lexicalEditor()` enables the Payload editor setup
|
||||
|
||||
### Collections and globals
|
||||
### Current Payload data model
|
||||
|
||||
Current CMS entities:
|
||||
This template is intentionally small right now.
|
||||
|
||||
- `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
|
||||
Current Payload entities:
|
||||
|
||||
### Block system
|
||||
- `users` collection in `apps/next/src/payload/collections/users.ts`
|
||||
- used by Payload admin itself
|
||||
- `landing-page` global in `apps/next/src/payload/globals/landing-page.ts`
|
||||
- stores the homepage layout as a block list
|
||||
|
||||
Payload page content is built from reusable blocks.
|
||||
There is no `pages` collection in this template yet.
|
||||
|
||||
Schema side:
|
||||
That means the current pattern is:
|
||||
|
||||
- `apps/next/src/payload/blocks/*.ts`
|
||||
- exported via `apps/next/src/payload/blocks/index.ts`
|
||||
- one global for one marketing page
|
||||
- one frontend route that reads that global
|
||||
- reusable block schemas that editors can reorder inside the global
|
||||
|
||||
Render side:
|
||||
## How the Landing Page Works
|
||||
|
||||
- `apps/next/src/components/payload/blocks/*.tsx`
|
||||
- selected by `apps/next/src/components/payload/blocks/render-blocks.tsx`
|
||||
### Schema side
|
||||
|
||||
The rule is simple: every Payload block needs both parts.
|
||||
The landing page is defined by:
|
||||
|
||||
- schema block: defines the fields editors can fill in
|
||||
- renderer block: turns that block data into frontend UI
|
||||
- `apps/next/src/payload/globals/landing-page.ts`
|
||||
- `apps/next/src/payload/globals/landing-page-blocks.ts`
|
||||
|
||||
If one side is missing, the admin or the frontend will be incomplete.
|
||||
`landing-page.ts` defines a single global with a `layout` field of type `blocks`.
|
||||
|
||||
### Frontend route flow
|
||||
`landing-page-blocks.ts` defines the actual editable block types, including:
|
||||
|
||||
Landing page route:
|
||||
- `hero`
|
||||
- `logoCloud`
|
||||
- `features`
|
||||
- `stats`
|
||||
- `techStack`
|
||||
- `testimonials`
|
||||
- `pricing`
|
||||
- `faq`
|
||||
- `cta`
|
||||
|
||||
### Frontend side
|
||||
|
||||
The frontend route is:
|
||||
|
||||
- `apps/next/src/app/(frontend)/page.tsx`
|
||||
|
||||
Contact page route:
|
||||
That route calls:
|
||||
|
||||
- `apps/next/src/app/(frontend)/contact/page.tsx`
|
||||
- `apps/next/src/lib/payload/get-landing-page-content.ts`
|
||||
|
||||
Service page route:
|
||||
That helper fetches the `landing-page` global from Payload and merges it with fallback
|
||||
content from:
|
||||
|
||||
- `apps/next/src/app/(frontend)/services/[slug]/page.tsx`
|
||||
- `apps/next/src/components/landing/content.ts`
|
||||
|
||||
Shared server fetch helpers:
|
||||
That fallback layer is important. It means the page can still render even if:
|
||||
|
||||
- `apps/next/src/lib/payload-helpers.tsx`
|
||||
- the Payload DB is empty
|
||||
- an editor saves partial content
|
||||
- a newly added field is missing from older content
|
||||
|
||||
Behavior:
|
||||
### Rendering flow
|
||||
|
||||
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
|
||||
The homepage flow is:
|
||||
|
||||
### Live preview and publish behavior
|
||||
1. `/` loads in `apps/next/src/app/(frontend)/page.tsx`
|
||||
2. the page checks whether `?preview=true` is enabled
|
||||
3. `getLandingPageContent(isPreview)` fetches the `landing-page` global
|
||||
4. the fetched global is merged with defaults from `apps/next/src/components/landing/content.ts`
|
||||
5. `LandingPageBuilder` renders the normalized block data
|
||||
6. `RefreshRouteOnSave` keeps preview mode refreshed after saves
|
||||
|
||||
There are two cooperating pieces:
|
||||
## Live Preview
|
||||
|
||||
Live preview is configured in:
|
||||
|
||||
- `apps/next/src/payload/globals/landing-page.ts`
|
||||
|
||||
The preview URL is:
|
||||
|
||||
- `/?preview=true`
|
||||
|
||||
The frontend bridge is:
|
||||
|
||||
- `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
|
||||
|
||||
That component uses Payload's live-preview utilities plus Next's router refresh so
|
||||
saved changes show up in the preview iframe.
|
||||
|
||||
Important requirement:
|
||||
|
||||
- `src/proxy.ts` and `src/lib/proxy/ban-sus-ips.ts` must not block valid Payload REST API
|
||||
requests under `/api`
|
||||
- `NEXT_PUBLIC_SITE_URL` must point to the correct frontend origin
|
||||
|
||||
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.
|
||||
If preview appears blank, stale, or disconnected, that is one of the first values to
|
||||
check.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -152,151 +142,68 @@ Payload depends on these env vars:
|
||||
|
||||
Why they matter:
|
||||
|
||||
- `PAYLOAD_SECRET` secures Payload sessions and server behavior
|
||||
- `PAYLOAD_SECRET` secures Payload
|
||||
- `PAYLOAD_DB_URL` connects Payload to Postgres
|
||||
- `NEXT_PUBLIC_SITE_URL` is used by live preview to target the frontend correctly
|
||||
- `NEXT_PUBLIC_SITE_URL` is used by live preview and frontend URL generation
|
||||
|
||||
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.
|
||||
All of them live in the single root `/.env` file.
|
||||
|
||||
## How To Create A New Page Like The Current Ones
|
||||
## Adding a New Landing-Page Block
|
||||
|
||||
This section assumes you want another page managed by the existing `pages` collection.
|
||||
If you want editors to control a new section type, add a new block.
|
||||
|
||||
### Option A: Create a new service page using the existing system
|
||||
### 1. Add the block schema
|
||||
|
||||
This is the simplest case.
|
||||
Update:
|
||||
|
||||
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>`
|
||||
- `apps/next/src/payload/globals/landing-page-blocks.ts`
|
||||
|
||||
Why this works without adding a new route:
|
||||
Add a new block object to the `landingPageBlocks` array.
|
||||
|
||||
- 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
|
||||
### 2. Extend the frontend content types
|
||||
|
||||
If the page should exist by default in new environments, also add it to
|
||||
`apps/next/src/payload/seed/service-pages.ts`.
|
||||
Update:
|
||||
|
||||
### Option B: Create another landing-style page with the same block approach
|
||||
- `apps/next/src/components/landing/content.ts`
|
||||
|
||||
If the page is not a service page, decide whether it belongs:
|
||||
You usually need to:
|
||||
|
||||
- in the existing `pages` collection with a new route, or
|
||||
- in a new Payload collection if the content model is materially different
|
||||
- add the new block TypeScript shape
|
||||
- add default content for it
|
||||
- add sanitizing / merging logic for it
|
||||
- include it in the landing-page block union
|
||||
|
||||
If it fits `pages`:
|
||||
### 3. Teach the landing-page builder to render it
|
||||
|
||||
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`
|
||||
Update the landing-page rendering layer in the landing components so the new block type
|
||||
actually appears on the page.
|
||||
|
||||
Example pattern:
|
||||
If you add schema without renderer support, editors can save the block but the frontend
|
||||
will not know what to do with it.
|
||||
|
||||
```tsx
|
||||
const page = await getPageBySlug('some-slug');
|
||||
### 4. Regenerate generated Payload files if needed
|
||||
|
||||
if (!page) return notFound();
|
||||
Useful commands:
|
||||
|
||||
return (
|
||||
<main>
|
||||
<RefreshRouteOnSave serverURL={env.NEXT_PUBLIC_SITE_URL} />
|
||||
<LivePreviewPage page={page} serverURL={env.NEXT_PUBLIC_SITE_URL} />
|
||||
</main>
|
||||
);
|
||||
```bash
|
||||
cd apps/next
|
||||
bun with-env bunx payload generate:types --config src/payload.config.ts
|
||||
bun with-env bunx payload generate:db-schema --config src/payload.config.ts
|
||||
```
|
||||
|
||||
## Copy-Paste Route Template
|
||||
That refreshes:
|
||||
|
||||
Use this when creating a new non-service Payload-backed page route.
|
||||
- `apps/next/payload-types.ts`
|
||||
- `apps/next/src/payload-generated-schema.ts`
|
||||
|
||||
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`.
|
||||
Do not hand-edit those files.
|
||||
|
||||
## Copy-Paste Block Template
|
||||
|
||||
Use this when you need a new reusable Payload block for a page migration.
|
||||
|
||||
Schema file example:
|
||||
Use this as a starting point when you want a new landing-page block.
|
||||
|
||||
```ts
|
||||
import type { Block } from 'payload';
|
||||
|
||||
export const ExampleBlock: Block = {
|
||||
{
|
||||
slug: 'exampleBlock',
|
||||
labels: {
|
||||
singular: 'Example Block',
|
||||
@@ -304,7 +211,7 @@ export const ExampleBlock: Block = {
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
@@ -324,330 +231,194 @@ export const ExampleBlock: Block = {
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
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.
|
||||
After adding it:
|
||||
|
||||
## How To Add A New Block
|
||||
1. add the block to `landingPageBlocks`
|
||||
2. add the TypeScript/content shape in `apps/next/src/components/landing/content.ts`
|
||||
3. add fallback content and sanitizers
|
||||
4. add rendering support in the landing components
|
||||
|
||||
If editors need a new section type, add a new block.
|
||||
## Copy-Paste Global-Backed Route Template
|
||||
|
||||
### 1. Create the Payload schema block
|
||||
Use this when you want another singular Payload-managed page in this template and you do
|
||||
not need a full `pages` collection yet.
|
||||
|
||||
Add a file under:
|
||||
This is the best next step for one-off pages like `about`, `contact`, or `pricing`.
|
||||
|
||||
- `apps/next/src/payload/blocks/<block-name>.ts`
|
||||
```tsx
|
||||
import { draftMode } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RefreshRouteOnSave } from '@/components/payload/refresh-route-on-save';
|
||||
import { getPayloadClient } from '@/lib/payload/get-payload';
|
||||
|
||||
This file defines the editor-facing fields.
|
||||
const AboutPage = async () => {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const payload = await getPayloadClient();
|
||||
|
||||
### 2. Export it from the block barrel
|
||||
const aboutPage = await payload.findGlobal({
|
||||
slug: 'about-page',
|
||||
draft: isPreview,
|
||||
});
|
||||
|
||||
Update:
|
||||
if (!aboutPage) return notFound();
|
||||
|
||||
- `apps/next/src/payload/blocks/index.ts`
|
||||
return (
|
||||
<main>
|
||||
{isPreview ? <RefreshRouteOnSave /> : null}
|
||||
{/* render the normalized Payload data here */}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
### 3. Register it in the `pages` collection
|
||||
export default AboutPage;
|
||||
```
|
||||
|
||||
Update:
|
||||
For this pattern you would also:
|
||||
|
||||
- `apps/next/src/payload/collections/pages.ts`
|
||||
1. add a new global in `apps/next/src/payload/globals/`
|
||||
2. register it in `apps/next/src/payload.config.ts`
|
||||
3. create the fetch/normalize helper in `apps/next/src/lib/payload/`
|
||||
4. create fallback content in the relevant component/content files
|
||||
|
||||
Add the block to the `layout.blocks` array.
|
||||
## When To Add Another Global vs. A `pages` Collection
|
||||
|
||||
### 4. Build the frontend renderer
|
||||
### Add another global when:
|
||||
|
||||
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
|
||||
- the page is singular
|
||||
- the route is fixed
|
||||
- the content model is custom
|
||||
- you only need a few editable marketing pages
|
||||
|
||||
Examples:
|
||||
|
||||
- hero section -> `landingHero` or `serviceHero`
|
||||
- card rows -> `cardGrid`
|
||||
- comparisons -> `tabularComparison` or `twoColumnComparison`
|
||||
- FAQ -> `faq`
|
||||
- pricing -> `pricingCards`
|
||||
- homepage
|
||||
- contact page
|
||||
- about page
|
||||
- pricing page
|
||||
|
||||
### Step 3: Create new blocks if the page needs them
|
||||
### Add a `pages` collection when:
|
||||
|
||||
If the existing block library is not enough:
|
||||
- you need many CMS-managed pages
|
||||
- page routing should be slug-driven
|
||||
- many pages share the same block system
|
||||
- editors should be able to create pages without new code for each route
|
||||
|
||||
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
|
||||
Examples:
|
||||
|
||||
Keep blocks composable and editor-friendly. Prefer a few clearly named fields over one big
|
||||
opaque content blob.
|
||||
- service pages
|
||||
- case studies
|
||||
- marketing subpages
|
||||
- partner pages
|
||||
|
||||
### Step 4: Move SEO into document fields
|
||||
## How To Migrate Another Hardcoded Page To Payload In This Repo
|
||||
|
||||
Map metadata into the Payload document:
|
||||
Because this template currently uses a global-backed landing page, the most natural next
|
||||
migration path is usually another Payload global.
|
||||
|
||||
- title -> `seo.metaTitle` or route fallback
|
||||
- meta description -> `seo.metaDescription`
|
||||
- keywords -> `seo.keywords`
|
||||
- noindex -> `seo.noIndex`
|
||||
- service-specific schema -> `structuredData`
|
||||
### Step 1: Audit the current page
|
||||
|
||||
If the page uses JSON-LD, keep the generation logic in the route and read values from the
|
||||
Payload document.
|
||||
Identify:
|
||||
|
||||
### Step 5: Replace the hardcoded route with a Payload-backed route
|
||||
- route path
|
||||
- metadata
|
||||
- JSON-LD or structured data
|
||||
- reusable sections
|
||||
- what should stay hardcoded vs. what should become editable
|
||||
|
||||
The route should:
|
||||
### Step 2: Decide the storage model
|
||||
|
||||
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`
|
||||
Ask:
|
||||
|
||||
For service pages, prefer reusing the dynamic service route instead of creating many
|
||||
one-off route files.
|
||||
- is this a one-off page? use another global
|
||||
- is this the beginning of many similar pages? consider creating a `pages` collection
|
||||
|
||||
### Step 6: Seed the migrated page
|
||||
### Step 3: Model the editable fields
|
||||
|
||||
If the page should exist locally or in fresh environments, add it to the seed system.
|
||||
Prefer structured fields and blocks over dumping everything into one rich text area.
|
||||
|
||||
This prevents the common failure mode where the route now expects Payload content but the
|
||||
database has no corresponding document yet.
|
||||
### Step 4: Add fallback content
|
||||
|
||||
### Step 7: Verify the full editor flow
|
||||
Keep the same resilience pattern used by the current landing page:
|
||||
|
||||
After migration, verify all of these:
|
||||
- define defaults in component/content files
|
||||
- merge Payload content into those defaults
|
||||
|
||||
- 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
|
||||
This avoids blank pages when content is incomplete.
|
||||
|
||||
### Step 8: Delete the old hardcoded page only after verification
|
||||
### Step 5: Wire the route
|
||||
|
||||
Do not remove the old page implementation until the Payload-backed route is proven working.
|
||||
Create a server route in `apps/next/src/app/(frontend)/...` that:
|
||||
|
||||
For the service-page migration in this repo, the safe order was:
|
||||
1. reads preview state
|
||||
2. fetches Payload data
|
||||
3. normalizes or merges it
|
||||
4. renders the page
|
||||
5. includes `RefreshRouteOnSave` in preview mode
|
||||
|
||||
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
|
||||
### Step 6: Verify preview and admin flow
|
||||
|
||||
That order avoids breaking the site in the middle of the migration.
|
||||
After migration, verify:
|
||||
|
||||
## 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
|
||||
- the route renders normally
|
||||
- the page updates from the admin
|
||||
- preview refresh works
|
||||
- the page still renders when content is partial
|
||||
|
||||
## Common Failure Modes
|
||||
|
||||
### 1. Page 404s after migration
|
||||
|
||||
Usually means the Payload document does not exist yet.
|
||||
### 1. Homepage renders old or partial content
|
||||
|
||||
Check:
|
||||
|
||||
- the route slug
|
||||
- the document slug
|
||||
- whether the seed ran
|
||||
- whether the landing-page global actually saved
|
||||
- whether preview mode is enabled
|
||||
- whether your fallback merge logic is masking a missing field
|
||||
|
||||
### 2. Publish/save shows `Not Found`
|
||||
|
||||
Usually means middleware or proxy rules are intercepting Payload API writes before they
|
||||
reach Payload.
|
||||
### 2. Live preview does not refresh
|
||||
|
||||
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`
|
||||
- `apps/next/src/components/payload/refresh-route-on-save.tsx`
|
||||
- the `admin.livePreview.url` value in the Payload global config
|
||||
|
||||
### 4. Dark mode preview looks wrong
|
||||
### 3. New block fields do not show up in the frontend
|
||||
|
||||
Usually means the theme classes or body-level theme tokens are not being applied inside the
|
||||
preview iframe.
|
||||
Usually means the schema changed but the frontend data contract did not.
|
||||
|
||||
Check the frontend layout and ensure `bg-background` / `text-foreground` are applied at the
|
||||
body level.
|
||||
Check:
|
||||
|
||||
- `apps/next/src/payload/globals/landing-page-blocks.ts`
|
||||
- `apps/next/src/components/landing/content.ts`
|
||||
- the landing-page render path
|
||||
|
||||
### 4. Payload admin works but the page looks empty
|
||||
|
||||
Usually means one of these:
|
||||
|
||||
- the block renderer support is missing
|
||||
- the global returned data that your merge layer is not handling yet
|
||||
- the page route is not reading the correct global or preview state
|
||||
|
||||
## 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`
|
||||
- payload global: `apps/next/src/payload/globals/landing-page.ts`
|
||||
- landing-page block schemas: `apps/next/src/payload/globals/landing-page-blocks.ts`
|
||||
- payload client helper: `apps/next/src/lib/payload/get-payload.ts`
|
||||
- landing-page fetch helper: `apps/next/src/lib/payload/get-landing-page-content.ts`
|
||||
- landing-page content defaults: `apps/next/src/components/landing/content.ts`
|
||||
- landing-page route: `apps/next/src/app/(frontend)/page.tsx`
|
||||
- preview refresh bridge: `apps/next/src/components/payload/refresh-route-on-save.tsx`
|
||||
- generated types: `apps/next/payload-types.ts`
|
||||
- generated db schema: `apps/next/src/payload-generated-schema.ts`
|
||||
|
||||
## Practical Rule Of Thumb
|
||||
|
||||
If the change is about editable marketing content, reach for Payload first.
|
||||
If the task is “make marketing content editable,” reach for Payload.
|
||||
|
||||
If the change is about business logic, authenticated workflows, tickets, invoices,
|
||||
appointments, or portal/admin operations, it probably belongs in Convex instead.
|
||||
If the task is “build application logic, auth, data workflows, or realtime product
|
||||
features,” it probably belongs in Convex instead.
|
||||
|
||||
Reference in New Issue
Block a user