Payload CMS platform
A multi-tenant headless CMS built to serve many sites from one codebase. Not deployed yet.
Problem
The goal is one codebase to replace managed WordPress for a group of sites, where each site's owner makes small edits without asking a developer. The first attempt forked a template per site onto a shared database. The forks drifted: several codebases wrote to one schema, some tables existed that no repo knew about, and fixes made upstream could not reach the forks.
Approach
Three passes, each written down. First, a custom kernel on Payload with a module SDK, a transactional outbox, an event log and a worker. Second, the stock Payload website template forked per client onto a shared Supabase database. Third, after a written audit in October, a rebuild: one headless CMS app on Payload's multi-tenant plugin, the anonymous API closed, server-only API keys per site, upload fields limited to the same tenant, per-tenant admin branding through Payload's own extension points, a publishing queue that revalidates cache tags and calls deploy hooks from Vercel Cron, signed live-preview links, S3-compatible media storage with tenant-prefixed keys, generated migrations with a drift check, and a tenant-isolation test suite that gates every release. Every decision goes into a numbered log.
Process
9 dated steps, from the project’s history
-
Started "Stronghold"
A multi-tenant Payload 3 and Next.js platform. Public sites resolve by hostname and admin traffic by membership.
-
Kernel phase 1 done
Module SDK with boundary checks at boot, a transactional outbox with event log and worker, contacts and memberships, storage and email providers, audited staff sessions, and a Docker image with web, worker and migrate roles. 72 unit and 85 integration tests passing.
-
Threw the kernel out
Replaced it with the clean Payload 3.87 website template and locked the scope: WordPress-style sites on their own domains, sharing one codebase.
-
One fork per client
Shared blocks, branding from environment variables, upstream sync, and tenants and sites on a shared Supabase project. The ERA and XRF forks started here.
-
The audit
I wrote a full audit of the platform. It mapped all the custom code layered over the template and found a stock seed route that could wipe every tenant and two ways to gain admin rights. I fixed those in both forks the same day and retired the fork model.
-
v2 foundation
pnpm and Turborepo monorepo, a headless apps/cms, the multi-tenant content model and access rules, a block library, a generated baseline migration, the tenant-isolation suite and CI. The isolation tests are never relaxed, only extended.
-
SDK, branding, publishing, two sites
A server-only SDK with cache tags, revalidate, preview and media handlers; per-tenant admin branding and a client dashboard; branded admin hosts; the publishing queue on Vercel Cron; live preview. ERA and XRF were ported to headless Next.js apps with content importers the same day. See Fig. 1, above.
-
Renamed to Payload CMS
Dropped the Stronghold name so anyone who picks the code up finds Payload's docs and community. Packages became @cms/* and the migrations were regenerated as one baseline.
The retired "Stronghold" shield, shown in the admin from October 13 until the rename on October 20, 2026. -
Real tenant branding, on a branch
Real job photos for ERA and XRF are on main. On a branch not yet merged, the importers also bring in the real company logos and set the admin brand colors from them.
Results
The CMS, the SDK and the ERA and XRF site apps run together as one monorepo. CI runs typecheck, lint, SDK tests, generated-file checks, migrations, a drift check, the isolation suite and the build on every change.
Lesson
Forks made with GitHub's template button share no history, so fixes can't be merged back. Several codebases on one schema drift in ways no single repo can rebuild. Much of my first custom layer re-implemented things Payload already does, so v2 uses Payload's own features first. And in Payload 3.87 the multi-tenant plugin adds no tenant filter for anonymous requests, so isolation has to come from access rules and be proven by tests.