11 KiB
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 viaPAYLOAD_DB_URLsecret: env.PAYLOAD_SECRETsecures Payloadcollections: [Users]currently registers only the Payload admin user collectionglobals: [LandingPage]registers the editable landing-page globallexicalEditor()enables the Payload editor setup
Current Payload data model
This template is intentionally small right now.
Current Payload entities:
userscollection inapps/next/src/payload/collections/users.ts- used by Payload admin itself
landing-pageglobal inapps/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.tsapps/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:
herologoCloudfeaturesstatstechStacktestimonialspricingfaqcta
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:
/loads inapps/next/src/app/(frontend)/page.tsx- the page checks whether
?preview=trueis enabled getLandingPageContent(isPreview)fetches thelanding-pageglobal- the fetched global is merged with defaults from
apps/next/src/components/landing/content.ts LandingPageBuilderrenders the normalized block dataRefreshRouteOnSavekeeps 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_URLmust 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_SECRETPAYLOAD_DB_URLNEXT_PUBLIC_SITE_URL
Why they matter:
PAYLOAD_SECRETsecures PayloadPAYLOAD_DB_URLconnects Payload to PostgresNEXT_PUBLIC_SITE_URLis 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:
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.tsapps/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.
{
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:
- add the block to
landingPageBlocks - add the TypeScript/content shape in
apps/next/src/components/landing/content.ts - add fallback content and sanitizers
- 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.
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:
- add a new global in
apps/next/src/payload/globals/ - register it in
apps/next/src/payload.config.ts - create the fetch/normalize helper in
apps/next/src/lib/payload/ - 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
pagescollection
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:
- reads preview state
- fetches Payload data
- normalizes or merges it
- renders the page
- includes
RefreshRouteOnSavein 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_URLapps/next/src/components/payload/refresh-route-on-save.tsx- the
admin.livePreview.urlvalue 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.tsapps/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.