Multi-Tenant Headless CMS Architecture: How to Manage Multiple Websites from One Backend

·20 min read

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.
Unified CMSNext.js on VercelAdmin UI + APIpages, /api/*Public website API/api/public/websites/:id/*Webhook engineHMAC-SHA256, outbox, retriesJobs + SEO enginetick, seo_jobs queueAdmin browserClerk session cookieWebsites A, B, Cconnector + API keycron-job.orgtick every minuteNeon PostgreSQLsource of truthCloudinarymedia + CDNGoogle + BingGA4, Search Console, BingClerk sessionAPI keysigned webhookevery minute
System architecture: one Next.js deployable exposes four logical services (admin UI and API, public website API, webhook engine, and jobs and SEO engine) that all share a single PostgreSQL database.

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.

Organization leveltenant root, one row per orgorganizationsusersorganization_membersinvitationsaudit_logsseo_connectionsWebsite levelone connected site per rowwebsitescategories, tagsmediaredirectswebhook_logsseo_metric_snapshotsContent levelworking copy + immutable revisionscontentscontent_revisionscontent_seocontent_tagscontent_linkscontent_seo_profiles1:n1:n
Tenancy hierarchy: 29 tables across three levels. Every website-scoped table carries a website_id column that is checked on every query, so one tenant's data is never reachable from another tenant's request.

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 /content and GET /content/{slug}, list and detail, with the body as Tiptap JSON plus a generated JSON-LD block
  • GET /posts and GET /posts/{slug}, the chronological content types only
  • GET /pages/{slug}, GET /categories, GET /tags
  • GET /sitemap, every live, indexable URL identity, keyset-paginated
  • GET /robots, the website's robots.txt text
  • GET /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.

PublishDB transaction, commitsOutbox rowstatus: PENDINGTick claims rowSKIP LOCKED, 60s lockSign + POSTHMAC-SHA256, 10s timeout2xxresponse?SUCCESSwebhook_logs: SUCCESSno: retry with backoff (30s to 1h, up to 6 attempts), then DEAD
Publishing runs as one database transaction that ends with an outbox row. Delivery happens after commit, so a failed publish never triggers a webhook, and a crashed delivery never blocks the next publish.

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.

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.

Publishtransaction commitspost_publish jobqueued in same transactionAnalyze contentrebuild content profileFind linksinternal + cross-site, scoredApply AUTO linksrelevance over thresholdSitemap is liveserved now, no extra stepBing: SubmitUrlqueued immediatelySubmit sitemapsGoogle + Bing, +10 minutesGoogle URL Inspectionscheduled, +24 hours
What happens after a publish commits. The SEO and link engine works from the CMS's own content database, never by crawling the live websites, and reports each step as done, queued, skipped, or failed in the editor's Links tab.

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.

  • Built as part of the AI automation and infrastructure work at Zobique Labs