Multi-Tenant Headless CMS Architecture: How to Manage Multiple Websites from One Backend
Every website we build at Zobique Labs eventually needs the same five things: somewhere non-technical for someone to publish content, meta tags and structured data that do not fight the framework, a sitemap that stays accurate, analytics that do not require logging into three separate dashboards, and internal links that get added instead of forgotten.
The obvious path is a CMS per site. A WordPress instance here, a headless CMS space there, a hand-rolled admin panel for the one built in plain HTML and CSS. Each choice solves the immediate problem and creates a longer one. Every new integration means learning a new API, writing new webhook handling, re-implementing the same SEO checks, and reconnecting Search Console from scratch. None of that work carries over to the next website.
So the actual question was not which CMS to use. It was: if content, SEO, and analytics are the same problem behind every website, why does each website need its own copy of that problem solved? That question is what Unified CMS, the internal platform we built at Zobique Labs, exists to answer. This is a walkthrough of how it is architected: the tenancy model, the API and webhook design, the transactional outbox that makes publishing safe, and the SEO and link engine sitting on top of it.
Multi-site is not multi-tenant
Those two terms get used interchangeably and they describe different problems. A multi-site CMS, WordPress Multisite being the canonical example, runs one installation that serves several sites which usually share a theming system and a database schema built around one content model. That is a good fit when the sites are variations of the same product.
A multi-tenant CMS is a different shape. Each tenant, an organization in our schema, can own websites that are independently designed, built on different frontend stacks, and hosted on different domains, with the CMS responsible for exactly one thing: the content, never the rendering.
The core principle that falls out of that distinction:
The CMS decides what is published. Each website decides how it looks.
The database is the single source of truth. Websites never store their own copy of CMS content. They read published content through an authenticated, read-only API and get told about changes through signed webhooks. Nothing about the design, the framework, or the deployment target of a connected website matters to the CMS at all.
One deployable, four logical services
The whole system is one Next.js 16 application (App Router, Turbopack) on Vercel, written in strict TypeScript, with no separate backend server. Inside that single deployable there are four logical services, all sharing one PostgreSQL database:
- Admin UI and API, authenticated by a Clerk session, used by the people who write and manage content.
- Public website API, authenticated by a per-website API key, used by the connected websites to read content.
- Webhook engine, a transactional outbox that signs and delivers change notifications to websites.
- Jobs and SEO engine, driven by a single scheduled endpoint that runs publishing, retries, analytics syncs, and SEO work.
One deployable was a deliberate choice, not a shortcut. Fewer moving parts
operationally, one place to reason about permissions end to end, one
migration history. The separation still exists in the code: pages call
services, route handlers stay thin and parse input with Zod, services hold
every permission check and business rule, and repositories are the only
layer allowed to touch the database, scoped by getWebsiteInOrg and
getContentInOrg. Nothing above the repository layer can construct a query
that reaches across tenants, because nothing above it is allowed to touch
the database directly.
The tenancy model: organization, website, content
Tenancy is a strict tree with three levels, and it is the single idea everything else in the system is built around.
An organization is the tenant: it owns members, invitations, integration
credentials, and linking rules. A website is one connected site with a
unique domain, an API key, and a webhook secret. Content belongs to exactly
one website. Every website-scoped table carries a website_id column, and
every query that reaches it is required to resolve that website through the
actor's own organization first (getWebsiteInOrg). Ask for another tenant's
website by id and the response is a plain 404, identical to asking for one
that does not exist, so the existence of other tenants' data is never
leaked through the shape of an error.
Row Level Security is enabled on all 29 tables with no policies defined. The application connects as the table owner, so RLS does not actually gate anything the app does today. It is there as defence in depth, in case this database is ever exposed through a connection that is not the application itself.
Content status is a five-state machine: DRAFT, SCHEDULED, PUBLISHED,
UNPUBLISHED, ARCHIVED. Publishing is a transition between those states,
never a flag flipped on a row, which matters for the next section.
Two authentication paths, for two different kinds of caller
Clerk answers who someone is. The CMS decides what they may do. Every
server request re-validates the session, resolves the active organization
from a cookie that is only honoured if the user is still an active member
of it, and loads an Actor object before touching a single row. Services
check named permissions, content:publish, webhooks:read,
integrations:manage, never role names directly, which is what lets roles
become more granular later (per-website grants, custom roles) without
touching any business logic.
Websites authenticate completely differently, because they are not people.
A website's identity comes from one thing only: its API key, sent as
Authorization: Bearer cms_sk_... and looked up by a SHA-256 hash. If the
website id in the URL path does not match the website the key belongs to,
the request gets a 403. Query parameters are never used for authorization,
on purpose, because a parameter is something a client can set to anything
it wants.
The public API: read-only, cached, and scoped to one website
Connected websites only ever read published revisions of their own
content, through /api/public/websites/{websiteKey}/.... Drafts, working
copies, secrets, and internal user data never leave the CMS through this
surface. The endpoints:
GET /contentandGET /content/{slug}, list and detail, with the body as Tiptap JSON plus a generated JSON-LD blockGET /postsandGET /posts/{slug}, the chronological content types onlyGET /pages/{slug},GET /categories,GET /tagsGET /sitemap, every live, indexable URL identity, keyset-paginatedGET /robots, the website's robots.txt textGET /redirects,GET /settings
Responses carry cache-control: private, no-cache and an ETag; a matching
If-None-Match returns a 304. The rate limit is 1,200 requests per website
per minute. A website's own connector uses these endpoints to build its
routes, its sitemap.xml, and its robots.txt. The CMS never renders a
single page of a connected website.
Publishing is a transaction, not a save button
This is the part that took the most care to get right, because it is the one place where a mistake is visible to a stranger visiting the website.
Publish runs as one database transaction, in order: permission check, apply
the editor's pending edits, validate (title, non-empty body, website not
disabled, SEO rules), snapshot a PUBLISH revision, set the status to
PUBLISHED and bump the version, add a 301 redirect if the live slug
changed, write a row to the webhook outbox, write a background SEO job,
write an audit entry, commit. If any step fails, none of it happens.
Webhook delivery and SEO work only start after that commit succeeds, so a
transaction that fails never notifies a website of a change that did not
happen.
That outbox row is the whole trick, and it is a standard pattern
(transactional outbox) applied literally: the notification is written in
the same transaction as the change it describes, so the two can never
disagree. A scheduled job then claims due rows with FOR UPDATE SKIP LOCKED under a 60-second lock, so multiple workers never double-send, and
a worker that crashes mid-delivery just has its lock expire and its row
picked up again. Delivery gets up to six attempts with exponential backoff
and 20% jitter (30s, 60s, 2m, 4m, 8m, capped at 1h). A 410 Gone stops
retrying immediately; anything else non-2xx, including a 401 caught during
a key rotation, gets retried. After the sixth failure the row is marked
DEAD and shows up in the CMS's own issue list, not silently.
Webhooks are signed notifications, not content delivery
The payload a website receives is deliberately thin:
{
"event_id": "evt_...",
"event": "content.updated",
"website_id": "web_htc527x6z9",
"content_id": "...",
"content_type": "blog",
"slug": "assam-tea-guide",
"previous_slug": "old-slug",
"version": 7,
"timestamp": "2026-09-25T10:00:00.000Z"
}
No title, no body, no SEO fields. The website's connector verifies the
signature, X-CMS-Signature: v1=hex(HMAC-SHA256(secret, timestamp + "." + eventId + "." + rawBody)), checks the timestamp against a five-minute
window, and checks the event id against a replay store, then calls
revalidateTag for the affected list, content, and settings tags. The
website then re-fetches the fresh content from the public API. The webhook
never carries the answer, only the fact that the answer changed. That keeps
the public API as the single source of truth even on the push side: there
is exactly one code path a website ever uses to read content, whether it
was told to look or asked on its own.
Seven events cover everything a website needs to know about:
content.created, content.published, content.updated, seo.updated,
content.unpublished, content.deleted, and website.settings.updated.
The SEO and link engine works from its own database
This is the part most CMSs either skip or fake. The engine here never crawls a website. It works entirely from content the CMS already has, runs after every publish, and reports exactly what it did.
Some SEO rules block publishing outright: a meta title over 120 characters,
a canonical URL that is not an absolute http(s) URL, custom JSON-LD that is
not valid or contains a <script> tag. Others are warnings: no meta
description and no excerpt, a duplicate title across pages, a focus keyword
missing from the title. Every published item also gets JSON-LD generated
from its title, description, image, dates, author, and canonical URL, with
any custom schema fields layered on top.
The link engine is the more interesting piece. Every live item gets a profile rebuilt whenever its published version changes: weighted terms (title weighted 3x, focus keyword 3x, headings 2x, description 1.5x, body 1x), candidate anchor phrases, topics from its category and tags, and a semantic vector. Relevance between two items is a weighted blend of three layers: topic overlap (15%), TF-IDF keyword cosine similarity (35%), and semantic similarity (50%, from Voyage AI embeddings when a key is configured, or a deterministic local hashed vector when it is not). A fourth layer acts as a gate rather than a score: the anchor phrase has to actually appear in a paragraph whose surrounding text, with the anchor words themselves excluded, is measurably about the target. In testing, a dedicated black tea brewing guide scores 95 against an Assam tea guide, a general morning drinks post scores 72, a coffee guide scores 9, and a car maintenance post scores 0.
Every safety rule here defaults conservative. Cross-site linking is off until an organization admin turns it on, and only between explicitly allow-listed website pairs. Automated placement needs a relevance score of at least 80 out of 100. There is a cap on links per article, a cap on links to the same target, a cap on how many times the same anchor text can point at the same target, and reciprocal cross-site pairs (A links to B, B links back to A) are never created automatically. Dismissed suggestions are never suggested again. Most importantly: the engine only wraps a link around words an author already wrote. It does not draft a bridging sentence to create somewhere to put a link, and there is no generative model anywhere in this pipeline writing prose that gets published.
Analytics without hammering three different providers on every page load
Google and Bing accounts are connected once per organization through OAuth,
then each website is mapped to its own GA4 property, Search Console
property, and Bing site. A background job pulls GA4 (Data API runReport),
Search Console (searchAnalytics.query, sitemaps.list), and Bing
Webmaster data every six hours per mapped website into a normalized
seo_metric_snapshots cache. Dashboards only ever read that cache. If
Google or Bing has an outage, the cached numbers stay visible with a plain
"data may be out of date" notice, and publishing never depends on either
provider being reachable.
Rolling several websites' numbers into one "all websites" view is where a lot of analytics dashboards quietly lie. Sessions, views, and clicks sum correctly. Average position does not: it has to be impression-weighted, the same way Search Console itself computes it, or a low-traffic page with a lucky ranking distorts the average. Engagement rate is session-weighted for the same reason. GA4 users are summed but explicitly labelled as a sum, not deduplicated across properties, because a visitor who reads two connected sites in the same week would otherwise be undercounted as one person. None of this is difficult. It is just easy to get wrong quietly, and a wrong number that looks confident is worse than a slow one that says how fresh it is.
What I would still change
A few limitations are current, not permanent. The link engine loads an
organization's live content profiles into memory to score candidates,
which is fine for a few thousand items and would need pgvector beyond
that. The webhook replay store and the rate limiter are both in-process by
default; either needs to move to Redis before running multiple application
instances against real traffic. Google has no indexing API for ordinary
pages, only for job postings and broadcast events, so requesting indexing
for a normal article is still a manual click through Search Console rather
than an API call, no matter how the rest of the pipeline is automated.
One limitation is deliberate rather than current: the link engine will never write a new sentence to create somewhere to put a link. That was a choice, not a gap. An automated system inserting unreviewed prose into someone's published content is a worse failure mode than an automated system that occasionally misses a link a human would have caught.
Why build it this way
This grew out of the same pattern that runs the rest of Zobique Labs. Every client project needed content management, SEO hygiene, and analytics that did not require a new integration each time, and solving that separately for each one was the actual cost, not the CMS itself. Building it once, as infrastructure with a real tenancy model instead of a shared login screen bolted onto separate installs, is what made the second and third website cheap instead of another full setup.
The business version of that argument, who this actually matters for and what changes operationally when content moves off per-site installs, is in Managing multiple websites at scale.
Links
- Built as part of the AI automation and infrastructure work at Zobique Labs