425 lines
11 KiB
Markdown
425 lines
11 KiB
Markdown
# Payload CMS In `convex-monorepo`
|
|
|
|
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.
|
|
|
|
## Current Scope
|
|
|
|
Payload currently powers the editable marketing layer inside the Next.js app.
|
|
|
|
Today that means:
|
|
|
|
- the landing page at `/`
|
|
- the admin UI at `/admin`
|
|
- the REST API under `/api`
|
|
- the GraphQL API under `/api/graphql`
|
|
- live preview for the landing page
|
|
|
|
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
|
|
|
|
### Core config
|
|
|
|
Payload is configured in `apps/next/src/payload.config.ts`.
|
|
|
|
Important pieces there:
|
|
|
|
- `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
|
|
|
|
### Current Payload data model
|
|
|
|
This template is intentionally small right now.
|
|
|
|
Current Payload entities:
|
|
|
|
- `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
|
|
|
|
There is no `pages` collection in this template yet.
|
|
|
|
That means the current pattern is:
|
|
|
|
- one global for one marketing page
|
|
- one frontend route that reads that global
|
|
- reusable block schemas that editors can reorder inside the global
|
|
|
|
## How the Landing Page Works
|
|
|
|
### Schema side
|
|
|
|
The landing page is defined by:
|
|
|
|
- `apps/next/src/payload/globals/landing-page.ts`
|
|
- `apps/next/src/payload/globals/landing-page-blocks.ts`
|
|
|
|
`landing-page.ts` defines a single global with a `layout` field of type `blocks`.
|
|
|
|
`landing-page-blocks.ts` defines the actual editable block types, including:
|
|
|
|
- `hero`
|
|
- `logoCloud`
|
|
- `features`
|
|
- `stats`
|
|
- `techStack`
|
|
- `testimonials`
|
|
- `pricing`
|
|
- `faq`
|
|
- `cta`
|
|
|
|
### Frontend side
|
|
|
|
The frontend route is:
|
|
|
|
- `apps/next/src/app/(frontend)/page.tsx`
|
|
|
|
That route calls:
|
|
|
|
- `apps/next/src/lib/payload/get-landing-page-content.ts`
|
|
|
|
That helper fetches the `landing-page` global from Payload and merges it with fallback
|
|
content from:
|
|
|
|
- `apps/next/src/components/landing/content.ts`
|
|
|
|
That fallback layer is important. It means the page can still render even if:
|
|
|
|
- the Payload DB is empty
|
|
- an editor saves partial content
|
|
- a newly added field is missing from older content
|
|
|
|
### Rendering flow
|
|
|
|
The homepage flow is:
|
|
|
|
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
|
|
|
|
## 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/refresh-route-on-save.tsx`
|
|
|
|
That component uses Payload's live-preview utilities plus Next's router refresh so
|
|
saved changes show up in the preview iframe.
|
|
|
|
Important requirement:
|
|
|
|
- `NEXT_PUBLIC_SITE_URL` must point to the correct frontend origin
|
|
|
|
If preview appears blank, stale, or disconnected, that is one of the first values to
|
|
check.
|
|
|
|
## Environment Variables
|
|
|
|
Payload depends on these env vars:
|
|
|
|
- `PAYLOAD_SECRET`
|
|
- `PAYLOAD_DB_URL`
|
|
- `NEXT_PUBLIC_SITE_URL`
|
|
|
|
Why they matter:
|
|
|
|
- `PAYLOAD_SECRET` secures Payload
|
|
- `PAYLOAD_DB_URL` connects Payload to Postgres
|
|
- `NEXT_PUBLIC_SITE_URL` is used by live preview and frontend URL generation
|
|
|
|
All of them live in the single root `/.env` file.
|
|
|
|
## Adding a New Landing-Page Block
|
|
|
|
If you want editors to control a new section type, add a new block.
|
|
|
|
### 1. Add the block schema
|
|
|
|
Update:
|
|
|
|
- `apps/next/src/payload/globals/landing-page-blocks.ts`
|
|
|
|
Add a new block object to the `landingPageBlocks` array.
|
|
|
|
### 2. Extend the frontend content types
|
|
|
|
Update:
|
|
|
|
- `apps/next/src/components/landing/content.ts`
|
|
|
|
You usually need to:
|
|
|
|
- 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
|
|
|
|
### 3. Teach the landing-page builder to render it
|
|
|
|
Update the landing-page rendering layer in the landing components so the new block type
|
|
actually appears on the page.
|
|
|
|
If you add schema without renderer support, editors can save the block but the frontend
|
|
will not know what to do with it.
|
|
|
|
### 4. Regenerate generated Payload files if needed
|
|
|
|
Useful commands:
|
|
|
|
```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
|
|
```
|
|
|
|
That refreshes:
|
|
|
|
- `apps/next/payload-types.ts`
|
|
- `apps/next/src/payload-generated-schema.ts`
|
|
|
|
Do not hand-edit those files.
|
|
|
|
## Copy-Paste Block Template
|
|
|
|
Use this as a starting point when you want a new landing-page block.
|
|
|
|
```ts
|
|
{
|
|
slug: 'exampleBlock',
|
|
labels: {
|
|
singular: 'Example Block',
|
|
plural: 'Example Blocks',
|
|
},
|
|
fields: [
|
|
{
|
|
name: 'heading',
|
|
type: 'text',
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'description',
|
|
type: 'textarea',
|
|
},
|
|
{
|
|
name: 'items',
|
|
type: 'array',
|
|
fields: [
|
|
{
|
|
name: 'label',
|
|
type: 'text',
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}
|
|
```
|
|
|
|
After adding it:
|
|
|
|
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
|
|
|
|
## Copy-Paste Global-Backed Route Template
|
|
|
|
Use this when you want another singular Payload-managed page in this template and you do
|
|
not need a full `pages` collection yet.
|
|
|
|
This is the best next step for one-off pages like `about`, `contact`, or `pricing`.
|
|
|
|
```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';
|
|
|
|
const AboutPage = async () => {
|
|
const { isEnabled: isPreview } = await draftMode();
|
|
const payload = await getPayloadClient();
|
|
|
|
const aboutPage = await payload.findGlobal({
|
|
slug: 'about-page',
|
|
draft: isPreview,
|
|
});
|
|
|
|
if (!aboutPage) return notFound();
|
|
|
|
return (
|
|
<main>
|
|
{isPreview ? <RefreshRouteOnSave /> : null}
|
|
{/* render the normalized Payload data here */}
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default AboutPage;
|
|
```
|
|
|
|
For this pattern you would also:
|
|
|
|
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
|
|
|
|
## When To Add Another Global vs. A `pages` Collection
|
|
|
|
### Add another global when:
|
|
|
|
- the page is singular
|
|
- the route is fixed
|
|
- the content model is custom
|
|
- you only need a few editable marketing pages
|
|
|
|
Examples:
|
|
|
|
- homepage
|
|
- contact page
|
|
- about page
|
|
- pricing page
|
|
|
|
### Add a `pages` collection when:
|
|
|
|
- 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
|
|
|
|
Examples:
|
|
|
|
- service pages
|
|
- case studies
|
|
- marketing subpages
|
|
- partner pages
|
|
|
|
## How To Migrate Another Hardcoded Page To Payload In This Repo
|
|
|
|
Because this template currently uses a global-backed landing page, the most natural next
|
|
migration path is usually another Payload global.
|
|
|
|
### Step 1: Audit the current page
|
|
|
|
Identify:
|
|
|
|
- route path
|
|
- metadata
|
|
- JSON-LD or structured data
|
|
- reusable sections
|
|
- what should stay hardcoded vs. what should become editable
|
|
|
|
### Step 2: Decide the storage model
|
|
|
|
Ask:
|
|
|
|
- is this a one-off page? use another global
|
|
- is this the beginning of many similar pages? consider creating a `pages` collection
|
|
|
|
### Step 3: Model the editable fields
|
|
|
|
Prefer structured fields and blocks over dumping everything into one rich text area.
|
|
|
|
### Step 4: Add fallback content
|
|
|
|
Keep the same resilience pattern used by the current landing page:
|
|
|
|
- define defaults in component/content files
|
|
- merge Payload content into those defaults
|
|
|
|
This avoids blank pages when content is incomplete.
|
|
|
|
### Step 5: Wire the route
|
|
|
|
Create a server route in `apps/next/src/app/(frontend)/...` that:
|
|
|
|
1. reads preview state
|
|
2. fetches Payload data
|
|
3. normalizes or merges it
|
|
4. renders the page
|
|
5. includes `RefreshRouteOnSave` in preview mode
|
|
|
|
### Step 6: Verify preview and admin flow
|
|
|
|
After migration, verify:
|
|
|
|
- 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. Homepage renders old or partial content
|
|
|
|
Check:
|
|
|
|
- whether the landing-page global actually saved
|
|
- whether preview mode is enabled
|
|
- whether your fallback merge logic is masking a missing field
|
|
|
|
### 2. Live preview does not refresh
|
|
|
|
Check:
|
|
|
|
- `NEXT_PUBLIC_SITE_URL`
|
|
- `apps/next/src/components/payload/refresh-route-on-save.tsx`
|
|
- the `admin.livePreview.url` value in the Payload global config
|
|
|
|
### 3. New block fields do not show up in the frontend
|
|
|
|
Usually means the schema changed but the frontend data contract did not.
|
|
|
|
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`
|
|
- 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 task is “make marketing content editable,” reach for Payload.
|
|
|
|
If the task is “build application logic, auth, data workflows, or realtime product
|
|
features,” it probably belongs in Convex instead.
|