Update all docs & md files

This commit is contained in:
2026-03-27 17:49:07 -05:00
parent e1f9cc4edf
commit 5ee4da55d3
3 changed files with 420 additions and 546 deletions

View File

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