CMS

Shipkit uses Payload CMS v3 as its primary content management system. The admin panel lives at /cms and stores data in the same PostgreSQL database as the app (in a separate schema).

Setup

Payload is auto-enabled when DATABASE_URL is set. Disable it explicitly with DISABLE_PAYLOAD=true.

DATABASE_URL=postgres://user:pass@host:5432/db
PAYLOAD_SECRET=your-secret-key   # Or use APP_SECRET as fallback

On first run, Payload auto-seeds default content (pages, settings). Control this with:

  • PAYLOAD_AUTO_SEED=false to disable auto-seeding
  • PAYLOAD_SEED_FORCE=true to force a reseed

Admin Panel

Access at /cms. Also reachable from the admin dashboard at /admin/cms, which shows CMS status and a manual seed button.

The admin panel provides:

  • Content editing for all collections
  • Media management (uploads)
  • User management (Payload-specific users)
  • Live preview for pages

Only a Payload user with role: "admin" can open /cms. See Access control.

Access control

Signing up with credentials creates a row in the Payload users collection, because that row is where the password hash for credentials sign-in lives. It does not grant any CMS access.

Access is decided by one field: role on the Payload user, either user or admin. Only an existing admin can write it, so an account cannot promote itself. The functions live in src/lib/payload/access.ts.

SurfaceAnonymousSigned-in, role: "user"role: "admin"
/cms panelnonoyes
Pages, Media, FAQs, Features, Testimonials: readyesyesyes
Pages, Media, FAQs, Features, Testimonials: writenonoyes
RBAC: read and writenonoyes
Settings global: readyesyesyes
Settings global: writenonoyes
Vercel Deploymentsnonoread and create
Users: read and updatenoown row onlyall rows
Users: create, delete, unlocknonoyes

Vercel Deployments is not publicly readable: its rows hold production deploy hook URLs, which are capability tokens. RBAC is not publicly readable either: its rows are the authorization model, so publishing them only describes the permission system to an attacker.

Server-side code reaches Payload through the Local API (payload.create, payload.update and friends), which defaults to overrideAccess: true. Seeding, credentials registration and the Vercel deployment recorder all work unchanged. The table above describes /cms and the /cms-api REST and GraphQL surfaces, which is where untrusted callers are.

Adding role to an existing database

Payload only pushes schema changes when NODE_ENV !== "production", so a production database needs the column added by hand before the deploy that introduces it. Otherwise every query against payload.users fails, which takes down both /cms and credentials sign-in.

CREATE TYPE payload.enum_users_role AS ENUM ('user', 'admin');
ALTER TABLE payload.users ADD COLUMN role payload.enum_users_role;

payload is the schema name from siteConfig.payload.dbSchemaName. This matches what Payload's own dev push creates, so the two will not fight. Existing rows read back as NULL, which counts as not an admin, so promote an account afterwards.

This is per database, not per repo. Every deployment with Payload enabled has its own payload.users table, so a downstream site that picks this change up through an upstream sync needs the same ALTER run against its own production database before that deploy goes out.

Getting the first admin

On a fresh install, the first account created through Payload's create-first-user screen at /cms becomes the admin. Application sign-ups always pass an explicit role: "user", so they never take that slot even when they happen to be first.

On an install that already has Payload users, promote one from a shell with the deployment's DATABASE_URL:

bun run payload:role you@example.com admin

The same command demotes:

bun run payload:role someone@example.com user

Collections

Pages

Block-based page composition. Each page has a title, slug, SEO metadata, and a layout field containing an array of blocks:

  • Hero - Hero section with title, subtitle, CTA
  • Content - Rich text content (Lexical editor)
  • Features - Feature list (references the Features collection)
  • Testimonials - Testimonial list (references the Testimonials collection)

Pages are accessible at /{slug}. Preview URL: /preview/pages/{slug}.

Media

File uploads with alt text. Supports S3 and Vercel Blob storage backends (see Storage). Public read access by default; writes need an admin.

Users

Payload admin authentication, and the password store for credentials sign-in. Separate from the app's NextAuth users, but synced: when a user signs up with credentials, they're created in both systems. A synced user gets role: "user" and no CMS access.

FAQs

Question/answer pairs with categories (General, Technical, Pricing, Support) and display ordering.

Features

Product features with name, description, category, plan association (Bones/Brains), badge (New/Popular/Pro), icon (Lucide icon name), and ordering.

Testimonials

User testimonials with name, role, company, text, avatar, and verified/featured flags.

RBAC

Role and permission definitions. Stores roles with associated permissions, scoped to resources (team, project, user, api-key, billing, settings) and actions (CRUD + manage). Admin-only for both read and write: nothing in the app reads this collection through the REST surface, and its rows describe the authorization model itself.

Vercel Deployments

Admin-only tracking of deployments initiated via the dashboard. Stores team/project/deployment IDs and URLs, including the production deploy hook URL. Rows are written server-side and can never be updated or deleted.

Settings (Global)

Site-wide configuration: site title, description, and seeding status tracking.

Unified Routing

Shipkit routes dynamic pages through both Payload and Builder.io:

  1. If Payload is enabled, it checks for a matching page by slug
  2. If not found and Builder.io is enabled, it checks Builder.io
  3. If neither has the page, returns 404

This happens in the catch-all route at src/app/(cms)/[...slug]/page.tsx.

Storage Backends

Media uploads can use:

BackendEnv VarsPlugin
S3AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME@payloadcms/storage-s3
Vercel BlobVERCEL_BLOB_READ_WRITE_TOKEN@payloadcms/storage-vercel-blob
LocalNoneDefault filesystem

Key Files

FilePurpose
src/payload.config.tsMain Payload configuration
src/lib/payload/collections/Collection definitions (Pages, Media, Users, FAQs, Features, Testimonials, RBAC, VercelDeployments)
src/lib/payload/access.tsShared access control for every collection and the Settings global
scripts/payload-set-role.tsbun run payload:role <email> <admin|user>
src/lib/payload/blocks/Page block definitions (hero, content, features, testimonials)
src/lib/payload/seed/Seed data
src/app/(app)/(admin)/admin/cms/page.tsxAdmin CMS status page