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

132
AGENTS.md
View File

@@ -5,6 +5,10 @@ repository. Read it fully before making any changes. It covers architecture, pat
constraints, and known issues — everything you need to work effectively without
breaking things or introducing inconsistencies.
This file is a living document. If your work changes architecture, workflows,
deployment scripts, Payload structure, important file paths, or known issues, update
`AGENTS.md` in the same task so the next agent inherits the right context.
---
## Table of Contents
@@ -294,6 +298,10 @@ The root `.env` is gitignored. The root `.env.example` is the committed template
shows all required variable names without real values. **Keep `.env.example` up to
date whenever you add a new env var.**
The root `/.env` is also the only required Docker Compose env file. The helper scripts
in `scripts/` load this same root file before calling `docker compose`, so do not
create or rely on a separate `docker/.env`.
### Complete variable reference
| Variable | Used By | Purpose | Sync to Convex? |
@@ -309,6 +317,19 @@ date whenever you add a new env var.**
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Next.js build | Sentry project name | No |
| `PAYLOAD_SECRET` | Payload CMS | Payload application secret | No |
| `PAYLOAD_DB_URL` | Payload CMS | Postgres connection string used by Payload | No |
| `NETWORK` | Docker Compose | External Docker network name | No |
| `NEXT_CONTAINER_NAME` | Docker Compose | Next.js container name | No |
| `NEXT_DOMAIN` | Docker Compose | Public Next.js domain used by compose interpolation | No |
| `BACKEND_CONTAINER_NAME` | Docker Compose | Convex backend container name | No |
| `DASHBOARD_CONTAINER_NAME` | Docker Compose | Convex dashboard container name | No |
| `BACKEND_DOMAIN` | Docker Compose | Public backend domain used in compose interpolation | No |
| `DASHBOARD_DOMAIN` | Docker Compose | Public dashboard domain used in compose interpolation | No |
| `INSTANCE_NAME` | Docker Compose | Self-hosted Convex instance name | No |
| `INSTANCE_SECRET` | Docker Compose | Self-hosted Convex instance secret | No |
| `CONVEX_CLOUD_ORIGIN` | Docker Compose | Public Convex API origin | No |
| `CONVEX_SITE_ORIGIN` | Docker Compose | Public Convex site/auth origin | No |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Docker Compose | Deployment URL passed to Convex dashboard | No |
| `POSTGRES_URL` | Docker Compose | Self-hosted Convex Postgres connection string | No |
| `CONVEX_SELF_HOSTED_URL` | Convex CLI | URL of the self-hosted Convex backend | No |
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Convex CLI | Admin key for the self-hosted backend | No |
| `CONVEX_SITE_URL` | Convex Auth | CORS domain for token exchange (localhost:3000 for dev) | Yes (Dashboard) |
@@ -384,6 +405,9 @@ Or set it directly in the Convex Dashboard.
Also update `/.env.example` with the new variable (empty value or a placeholder).
If the variable is used by Docker Compose or one of the helper scripts in `scripts/`,
it still belongs in the same root `/.env`.
---
## 6. Dependency Management — The Catalog System
@@ -781,16 +805,43 @@ pieces are:
generated admin/API files
- **`apps/next/src/payload/collections/`** and `apps/next/src/payload/globals/` — the
editable Payload schema files you should actually work in
- **`apps/next/src/payload/globals/landing-page-blocks.ts`** — the current block schema
definitions for the landing-page global
- **`apps/next/src/lib/payload/`** — server helpers for obtaining the cached Payload
client and fetching CMS content
- **`apps/next/src/components/payload/refresh-route-on-save.tsx`** — enables live
preview refreshes for the landing page when `?preview=true` is present
Current state of the template:
- Payload currently powers the homepage via the `landing-page` global, not a `pages`
collection
- the editable block definitions live in
`apps/next/src/payload/globals/landing-page-blocks.ts`
- the frontend reads the saved global through
`apps/next/src/lib/payload/get-landing-page-content.ts`
- saved Payload content is merged with resilient defaults from
`apps/next/src/components/landing/content.ts`, so the page still renders even when
content is partial or missing fields
- live preview works by loading `/` with `?preview=true` and rendering
`RefreshRouteOnSave`
If you need a deeper walkthrough, read `docs/payload-cms.md`. That file should stay in
sync with the actual Payload implementation in this repo.
The current homepage is backed by the `landing-page` Payload global. The server page at
`apps/next/src/app/(frontend)/page.tsx` calls
`apps/next/src/lib/payload/get-landing-page-content.ts`, which merges saved Payload
content with defaults from `apps/next/src/components/landing/content.ts`.
When adding more Payload-managed marketing content in this template, there are two
reasonable paths:
1. add another Payload global plus a matching frontend route if the page is singular
and custom
2. introduce a `pages` collection once you need many reusable CMS-managed pages with
shared routing patterns
### The dual Convex provider setup
Two providers are required and both must be present:
@@ -1148,22 +1199,15 @@ The Convex backend and dashboard are long-running and rarely need to be rebuilt.
The typical workflow when you've made changes to the Next.js app:
```bash
# On the production server (via SSH)
cd docker/
# Build the Next.js app with current source
sudo docker compose build next-app
# Once build succeeds, bring up the new container
sudo docker compose up -d next-app
./scripts/build-next-app
```
If you need to start everything from scratch:
```bash
sudo docker compose up -d convex-backend convex-dashboard
./scripts/docker-compose up -d backend dashboard
# Wait for backend to be healthy, then:
sudo docker compose up -d next-app
./scripts/docker-compose up -d next
```
### The `.dockerignore` situation (known issue)
@@ -1180,11 +1224,22 @@ Do not un-comment those lines without understanding the full implications — re
`.env` from the build context would break Sentry source map uploads and
`NEXT_PUBLIC_*` variable baking into the client bundle.
**Note on `docker/.env`:** This file is entirely separate and serves a different
purpose. It is read by Docker Compose itself (not the build) to interpolate the
`${VARIABLE}` placeholders in `compose.yml` — things like container names, network
names, and Convex origin URLs. It is not the same as the root `/.env` and the two
are not interchangeable.
Docker Compose now reads from the root `/.env`, and the helper scripts in `scripts/`
wrap Compose so you do not need to repeatedly pass `--env-file` and `-f`.
### Docker helper scripts
The preferred deployment workflow now uses the helper scripts in `scripts/`:
- `scripts/docker-compose` — wrapper for custom Compose commands using the repo's root
`/.env` and `docker/compose.yml`
- `scripts/build-next-app` — build and restart the Next container
- `scripts/update-next-app` — `git pull`, then build and restart the Next container
- `scripts/update-convex` — pull and restart the backend/dashboard containers
- `scripts/generate-convex-admin-key` — run the backend container's admin key script
The `scripts/docker-compose` wrapper also translates short aliases like `next`,
`backend`, and `dashboard` into the container names defined in the root `/.env`.
### Convex data persistence
@@ -1192,13 +1247,12 @@ are not interchangeable.
This directory is gitignored. Back it up before any server migrations, restarts, or
image updates.
### `generate_convex_admin_key`
### `generate-convex-admin-key`
A bash script in `docker/` that generates the admin key from a running Convex backend:
A bash script in `scripts/` generates the admin key from a running Convex backend:
```bash
cd docker/
./generate_convex_admin_key
./scripts/generate-convex-admin-key
```
This runs the key generation script inside the `convex-backend` container and prints
@@ -1281,11 +1335,13 @@ deployment), these things should be updated:
repository URL, and quick-start text
- `apps/next/src/payload/globals/landing-page.ts` — update or replace the current landing
page global schema
- `apps/next/src/payload/globals/landing-page-blocks.ts` — update the landing-page block
definitions if the marketing content model changes
- `apps/next/src/payload.config.ts` — add/remove Payload collections and globals as the
project evolves
- Root `package.json` — update the workspace `name` field to fit the new project
- `/.env` — fill out all values for the new deployment
- `docker/.env` — fill out container names and domain URLs
- `/.env` — also fill out the Docker Compose container names and domain URLs
---
@@ -1323,12 +1379,18 @@ deployment), these things should be updated:
### Adding or changing Payload content
1. Edit the relevant schema file in `apps/next/src/payload/collections/` or
`apps/next/src/payload/globals/`
2. Register the collection/global in `apps/next/src/payload.config.ts`
3. Fetch the content from server code via `apps/next/src/lib/payload/`
1. For the current homepage, edit the landing-page global or its block definitions in
`apps/next/src/payload/globals/landing-page.ts` and
`apps/next/src/payload/globals/landing-page-blocks.ts`
2. Keep the frontend data contract in sync with
`apps/next/src/components/landing/content.ts`
3. Fetch or merge the content through `apps/next/src/lib/payload/`
4. If you need fresh Payload types, regenerate `apps/next/payload-types.ts`
5. Do not hand-edit the generated files in `apps/next/src/app/(payload)/`
5. If you change the database schema shape, regenerate
`apps/next/src/payload-generated-schema.ts`
6. Do not hand-edit the generated files in `apps/next/src/app/(payload)/`
7. If you introduce additional Payload-managed pages or collections, update
`docs/payload-cms.md` and this `AGENTS.md` file in the same task
### Adding a new environment variable
@@ -1443,12 +1505,13 @@ on Gitea) for automated lint and typecheck on pull requests.
Currently:
- `packages/backend/scripts/generateKeys.mjs` — generates JWT keys (run manually)
- `docker/generate_convex_admin_key` — bash script to get the admin key from Docker
- `scripts/generate-convex-admin-key` — bash script to get the admin key from Docker
- `scripts/docker-compose` and related helpers — wrap Docker Compose with the root
`/.env`
These are separate workflows that both need to be run once when setting up a new
deployment. Explore combining these into a unified setup script — potentially one that
generates the keys AND automatically syncs them to Convex in one step, possibly
moving everything to `docker/scripts/`.
generates the keys AND automatically syncs them to Convex in one step.
---
@@ -1499,10 +1562,11 @@ bun dev # Runs convex dev (push functions + watch)
bun setup # Runs convex dev --until-success (push once then exit)
bun with-env npx convex env set VAR "value" # Sync env var to Convex deployment
# ── Docker (run from docker/) ─────────────────────────────────────────────────
sudo docker compose up -d convex-backend convex-dashboard # Start Convex
sudo docker compose build next-app # Build Next.js image
sudo docker compose up -d next-app # Deploy Next.js
sudo docker compose logs -f # Stream all logs
./generate_convex_admin_key # Get admin key
# ── Docker / Deployment ───────────────────────────────────────────────────────
./scripts/docker-compose ps # Show compose status
./scripts/docker-compose up -d backend dashboard # Start Convex
./scripts/build-next-app # Build and deploy Next.js app
./scripts/update-next-app # git pull + rebuild Next.js app
./scripts/update-convex # Pull and restart Convex services
./scripts/generate-convex-admin-key # Get admin key
```

119
README.md
View File

@@ -1,6 +1,8 @@
# Convex Turbo Monorepo
A production-ready Turborepo starter with Next.js, Expo, and self-hosted Convex backend. Built with TypeScript, Tailwind CSS, and modern tooling.
A production-ready Turborepo starter with Next.js, Expo, self-hosted Convex, and
embedded Payload CMS for live-editable marketing content. Built with TypeScript,
Tailwind CSS, and modern tooling.
---
@@ -22,6 +24,7 @@ A production-ready Turborepo starter with Next.js, Expo, and self-hosted Convex
- **Framework:** Next.js 16 (App Router) + Expo 54
- **Backend:** Convex (self-hosted)
- **Auth:** @convex-dev/auth with Authentik OAuth & Password providers
- **CMS:** Payload CMS embedded inside the Next.js app
- **Styling:** Tailwind CSS v4 + shadcn/ui
- **Language:** TypeScript (strict mode)
- **Package Manager:** Bun
@@ -64,39 +67,45 @@ git remote add origin https://your-git-host.com/your/new-repo.git
---
### Step 2 — Configure the Docker Environment
The `docker/` directory contains everything needed to run the Convex backend and the
Next.js app in production.
### Step 2 — Configure the Single Root Environment File
```bash
cd docker/
cp .env.example .env
```
Edit `docker/.env` and fill in your values. The variables below the Next.js app section
control the Convex containers — you'll need to choose:
The root `/.env` is the single required env file for this repo. It is used for:
- app/package runtime env vars
- Next.js build-time env vars
- Payload env vars
- Docker Compose interpolation
- helper scripts in `scripts/`
Fill it in with your values. The Docker-related variables in the same file control the
Convex containers and Next.js container — you'll need to choose:
- `INSTANCE_NAME` — a unique name for your Convex instance
- `INSTANCE_SECRET` — a secret string (generate something random)
- `CONVEX_CLOUD_ORIGIN` — the public HTTPS URL for your Convex backend API (e.g. `https://api.convex.example.com`)
- `CONVEX_SITE_ORIGIN` — the public HTTPS URL for Convex Auth HTTP routes (e.g. `https://api.convex.example.com`)
- `NEXT_PUBLIC_DEPLOYMENT_URL` — the URL for the Convex dashboard (e.g. `https://dashboard.convex.example.com`)
- `NEXT_CONTAINER_NAME`, `BACKEND_CONTAINER_NAME`, and `DASHBOARD_CONTAINER_NAME` — the Docker service container names
Do not create or rely on a separate `docker/.env`.
---
### Step 3 — Start the Convex Containers
```bash
cd docker/
sudo docker compose up -d convex-backend convex-dashboard
./scripts/docker-compose up -d backend dashboard
```
Wait a moment for `convex-backend` to pass its health check, then verify both
containers are running:
```bash
sudo docker compose ps
./scripts/docker-compose ps
```
Reverse-proxy the two Convex services through nginx-proxy-manager (or your preferred
@@ -110,8 +119,7 @@ can proceed.
With the backend container running, generate the admin key:
```bash
cd docker/
./generate_convex_admin_key
./scripts/generate-convex-admin-key
```
Copy the printed key — you'll need it as `CONVEX_SELF_HOSTED_ADMIN_KEY` in the root
@@ -119,21 +127,16 @@ Copy the printed key — you'll need it as `CONVEX_SELF_HOSTED_ADMIN_KEY` in the
---
### Step 5 — Configure Root Environment Variables
### Step 5 — Finish Configuring Root Environment Variables
Create the root `.env` file:
```bash
# From the repo root
cp .env.example .env
```
Fill out all values in `/.env`:
Fill out all values in root `/.env`:
```bash
# Next.js
NODE_ENV=development
SENTRY_AUTH_TOKEN= # From your self-hosted Sentry
PAYLOAD_SECRET= # openssl rand -hex 32
PAYLOAD_DB_URL=postgresql://user:password@host:5432/db_name
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.example.com
@@ -154,6 +157,18 @@ USESEND_FROM_EMAIL=My App <noreply@example.com>
AUTH_AUTHENTIK_ID=
AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/
# Docker Compose
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=next-app
BACKEND_CONTAINER_NAME=convex-backend
DASHBOARD_CONTAINER_NAME=convex-dashboard
INSTANCE_NAME=convex
INSTANCE_SECRET=
CONVEX_CLOUD_ORIGIN=https://api.convex.example.com
CONVEX_SITE_ORIGIN=https://convex.convex.example.com
NEXT_PUBLIC_DEPLOYMENT_URL=https://dashboard.convex.example.com
POSTGRES_URL=postgresql://user:password@host:5432/convex
```
---
@@ -209,6 +224,26 @@ bun dev # All apps (Next.js + Expo + Backend)
- **Next.js:** http://localhost:3000
- **Convex Dashboard:** https://dashboard.convex.example.com
### Docker Helper Scripts
This repo includes helper scripts so you do not have to keep passing `--env-file` and
`-f` manually to Docker Compose.
Useful commands:
```bash
./scripts/docker-compose ps
./scripts/docker-compose up -d backend dashboard
./scripts/docker-compose up -d next
./scripts/build-next-app
./scripts/update-next-app
./scripts/update-convex
./scripts/generate-convex-admin-key
```
`./scripts/docker-compose` also accepts short service aliases like `next`, `backend`,
and `dashboard` and maps them to the container names from root `/.env`.
---
## Development Commands
@@ -278,7 +313,13 @@ convex-monorepo/
├── docker/ # Self-hosted deployment
│ ├── compose.yml
│ ├── Dockerfile
│ └── .env.example
│ └── data/ # Convex data volume (gitignored in real use)
├── scripts/ # Docker/deployment helper scripts using root .env
│ ├── docker-compose
│ ├── build-next-app
│ ├── update-next-app
│ └── update-convex
├── turbo.json # Turborepo configuration
└── package.json # Root workspace & catalogs
@@ -298,6 +339,7 @@ convex-monorepo/
### Next.js App
- **App Router:** Next.js 16 with React Server Components
- **Editable Marketing Content:** Payload-backed landing page with live preview
- **Data Preloading:** SSR data fetching with `preloadQuery` + `usePreloadedQuery`
- **Middleware:** Route protection & IP-based security (`src/proxy.ts`)
- **Styling:** Tailwind CSS v4 with dark mode (OKLCH-based theme)
@@ -327,26 +369,18 @@ convex-monorepo/
### Production Deployment (Docker)
Once the Convex containers are running (they only need to be started once), deploying
a new version of the Next.js app is a two-command workflow:
a new version of the Next.js app is a one-command workflow:
```bash
# SSH onto your server, then:
cd docker/
# Build the new image
sudo docker compose build next-app
# Deploy it
sudo docker compose up -d next-app
./scripts/build-next-app
```
To start all services from scratch:
```bash
cd docker/
sudo docker compose up -d convex-backend convex-dashboard
./scripts/docker-compose up -d backend dashboard
# Wait for backend health check to pass, then:
sudo docker compose up -d next-app
./scripts/docker-compose up -d next
```
**Services:**
@@ -359,11 +393,10 @@ sudo docker compose up -d next-app
### Production Checklist
- [ ] Fill out `docker/.env` with your domain names and secrets
- [ ] Fill out root `/.env` with all app, Payload, and Docker Compose values
- [ ] Start `convex-backend` and `convex-dashboard` containers
- [ ] Generate and set `CONVEX_SELF_HOSTED_ADMIN_KEY` via `./generate_convex_admin_key`
- [ ] Generate and set `CONVEX_SELF_HOSTED_ADMIN_KEY` via `./scripts/generate-convex-admin-key`
- [ ] Reverse-proxy both Convex services via nginx-proxy-manager with SSL
- [ ] Fill out root `/.env` with all environment variables
- [ ] Generate JWT keys and sync all env vars to Convex (`bun with-env npx convex env set ...`)
- [ ] Update `CONVEX_SITE_URL` in the Convex Dashboard to your production Next.js URL
- [ ] Build and start the `next-app` container
@@ -374,6 +407,7 @@ sudo docker compose up -d next-app
## Documentation
- **[AGENTS.md](./AGENTS.md)** — Comprehensive guide for AI agents & developers
- **[docs/payload-cms.md](./docs/payload-cms.md)** — How Payload is wired up and how to extend it in this template
- **[Convex Docs](https://docs.convex.dev)** — Official Convex documentation
- **[Turborepo Docs](https://turbo.build/repo/docs)** — Turborepo documentation
- **[Next.js Docs](https://nextjs.org/docs)** — Next.js documentation
@@ -402,11 +436,16 @@ import { api } from '@gib/backend/convex/_generated/api.js';
### Docker containers won't start
1. Check Docker logs: `sudo docker compose logs`
2. Verify environment variables in `docker/.env`
1. Check Docker logs: `./scripts/docker-compose logs`
2. Verify environment variables in root `/.env`
3. Ensure the `nginx-bridge` network exists: `sudo docker network create nginx-bridge`
4. Check that the required ports (3210, 6791) are not already in use
### Only one `.env` file matters
Use the root `/.env` for everything in this repo, including Docker helper scripts and
Docker Compose interpolation. Do not create or rely on a separate `docker/.env`.
### Auth doesn't work in production
Make sure `CONVEX_SITE_URL` is set to your production Next.js URL in the **Convex

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.