Custom Blog Management (CBM)

A single WordPress install that publishes to many customer domains — authored once, rendered server-side, and served at each domain's own edge by a Cloudflare Worker.

Client
EpsolDev
Category
Platform Engineering
Completed
2026-08-14
Built with
WordPress, PHP, React.js, TypeScript, Vite, Cloudflare Workers, MySQL, REST API
Topics
API Development, Backend, CMS, DevOps
Custom Blog Management (CBM)

WordPress Plugin Cloudflare Worker MCP Server In production

Running content for several client domains normally means one WordPress install per site. Ten clients means ten sets of updates, ten places for SEO markup to drift apart, ten cron schedules to babysit — and still no single answer to the only question that matters: which of our pages are actually indexed?

CBM collapses that into one control plane. A post is authored once, stored centrally, rendered server-side, and served under each client's own domain by a Cloudflare Worker.

The central install never appears in a URL. Readers see client-domain.com/blogs/the-post, Google indexes that domain, and the content is still edited in exactly one place.


Architecture at a glance

LayerWhat it isNotes
AuthoringReact 18 + TypeScript SPA inside wp-admin26 routes, CodeMirror 6 editors
Storage5 custom post types, 11 custom tables40 sequential migrations
RenderingServer-side, PHPMarkdown body + template + auto JSON-LD
DeliveryOne Cloudflare Worker, many zonesRoutes provisioned per client via API
Automation21 cron jobs, 241 MCP toolsSync under a ceiling, queued above it
Contract2 REST namespaces, 248 route registrations28 controllers

Author once, render anywhere

The CBM Templates screen: reusable single-post and blog-index layouts, organised into per-client folders

A post body is Markdown. A site binds to a template, and templates come in two kinds — single-post and blog-index — organised into folders per client, with an optional ZIP upload for asset bundles.

Templates are resolved by a Handlebars subset written from scratch in PHP. No Composer, no LightnCandy, no template compiler:

// Innermost-first passes, repeated until the template stops changing.
// Arbitrary nesting works without ever building an AST.
do {
    $before = $tpl;
    $tpl = $this->pass_conditionals($tpl, $ctx);
    $tpl = $this->pass_loops($tpl, $ctx);
    $tpl = $this->pass_variables($tpl, $ctx);
} while ($tpl !== $before);

It supports {{var}}, {{{raw}}}, dot paths, #if / #unless / #each with {{else}}, {{@index}} and registered helpers. The reason to own ~300 lines instead of pulling a dependency: this runs inside someone else's WordPress, where a Composer autoloader is a conflict waiting to happen.

On top of the body, 20 JSON-LD schema types are injected automatically based on what the content actually contains:

Article · BlogPosting · FAQPage · HowTo · Product · Review · Event · Organization · Person · LocalBusiness · WebPage · Speakable · BreadcrumbList · TouristAttraction · TouristTrip · VideoObject · AggregateRating · Author — plus a detector, a registry and a builder.


The edge layer

The CBM Sites screen: every managed domain with its bound template and published post count

One Worker script serves every zone. The wrangler.toml deliberately contains no route blocks — routes for /blogs/*, /sitemap-blogs.xml and the IndexNow key file are created per client zone through the Cloudflare API from the admin UI. Onboarding a domain is an API call, not a redeploy.

The plugin can even deploy its own Worker: a bundled ESM build is uploaded to the client's Cloudflare account, but only when the script is missing or its content hash changed.

Cache behaviour

ResponseFreshStale-while-revalidate
200 HTML / XML1 hour23 hours
40460 seconds5 minutes
Origin 5xx or network error—serves stale with X-CBM-Stale

The cache key includes the request host, so two client domains can never collide on the same path.

Two details that came from real bugs

Canonicals are redirected, not rewritten. An earlier rewrite-based approach returned 200 on three different URL shapes for the same content, and Search Console duly reported "Alternate page with proper canonical tag". The Worker now issues a 301 at the edge.

The Worker announces itself. Every response carries x-cbm-worker and x-cbm-worker-version, because cf-ray only proves a Cloudflare Worker answered — not that this one is on the route. The admin's route probe reads that header.

Drift detection never self-heals A daily job compares each site's expected Worker routes against live Cloudflare state and writes a report. Repair stays an explicit operator action — an automated fixer that is wrong at 3am is worse than a red badge on a dashboard.


Getting indexed, and knowing whether you are

The CBM Search Engine Indexing screen: multi-engine submission, per-engine quota guards and the background queue

Publishing is the easy half. The hard half is proving a URL made it into the index.

Submission

EngineMechanism
Bing, Yandex, Naver, SeznamIndexNow
GoogleIndexing API, RS256 JWT service-account OAuth
BingWebmaster SubmitUrlBatch
Baidu主动推送 push API

Each engine runs inside its own try/catch, so one engine failing cannot take down the batch. Submission fires automatically on publish, and sends URL_DELETED on unpublish.

Daily quota guards stop before the ceiling rather than after it:

  • Google skips at 180 of 200
  • Bing skips at 90 of 100

Bulk runs go through a background queue that a 5-minute cron paces across engines, rather than firing everything at once.

Coverage

A per-URL store tracks a six-state model, rechecked hourly, with capped re-submission back through the same quota-guarded queue:

  • indexed — confirmed present
  • not_indexed — confirmed absent, eligible for re-submit
  • pending — submitted, not yet checked
  • uncertain — checker returned an ambiguous verdict
  • stalled — repeatedly checked, never resolved
  • error — the check itself failed

Local search

Rank Tracker plots a Google Business Profile's local-pack position as a geo-grid heatmap — every keyword measured at every grid point, rolled up into a visibility score.

The interesting constraint is cost and latency. The SERP provider's live endpoint handles roughly one task per request, so a 7×7 grid across 5 keywords is 245 separate calls. Firing them sequentially would take minutes:

// One request per (keyword × grid point), issued concurrently
// under a fixed concurrency cap. An N-point scan costs about
// ceil(N / concurrency) round trips instead of N.
$mh = curl_multi_init();

Scans under 16 calls run synchronously; anything larger drains through a background queue with a live progress bar, under a hard cap of 1000 calls per scan.


Built to be driven by machines

The CBM MCP console: registered tool count, tokens, transport and health state

Every operation the admin UI can perform is also callable by an AI agent or an automation. The MCP server exposes 241 tools from an auto-discovering registry — drop a file into the tools directory that registers itself at file scope and it appears in tools/list. No central manifest to update, nothing to forget.

{ "jsonrpc": "2.0", "method": "tools/list", "id": 1 }

Authorisation

  • OAuth 2.1 authorization server and resource server on the same WordPress origin. The issuer deliberately carries the /wp-json/ path, because some hosts' webserver answers /.well-known/* before WordPress ever sees it — with an auto-provisioned rewrite as a fallback.
  • Opaque bearer tokens shaped cbm_mcp_ID.MAC, where the second half is an HMAC integrity check independent of storage. The server persists only a SHA-256 hash; the plaintext is shown exactly once, at mint time.
  • Tokens can be pinned to a single client site. Tools stay visible to unpinned tokens and are gated at call time inside each adapter — a deliberate choice, so an agent planning a workflow can see what exists and gets a clear refusal rather than a mysteriously empty catalogue.

Connections

The CBM Connections screen: every third-party integration with its live status and per-site or global scope

Twenty third-party services are managed from one screen, each with a live status and a scope — global, per-site, or both. Press [[Test all]] and every credential is re-checked in place.

Cloudflare · Google Search Console · Google Business Profile · Google Drive · Google Photos · Google Places · DataForSEO · n8n · Bing Webmaster · IndexNow · Instagram · Facebook · YouTube · LinkedIn · TikTok · X · Cloudinary · Perplexity · GitHub Pages · S3-compatible storage · SMTP

Credentials never round-trip. Secrets are encrypted at rest with libsodium crypto_secretbox, falling back to AES-256-CBC, with keys derived from the WordPress salts and blobs tagged so both encodings coexist during rotation. Read paths mask; nothing decrypted is ever returned to REST, MCP or the SPA.


Decisions worth naming

Heavy work degrades to a queue instead of timing out. Three subsystems share one shape — a synchronous ceiling, a chunk size, and a hard cap:

SubsystemRuns sync underChunkHard cap
Rank Tracker16 calls161 000
Local SEO Projects400 items2501 000
Mass Page Builder60 pages252 000

Past the ceiling the job drains through a 5-minute cron with live progress. The caps are not arbitrary — at roughly $0.002 per SERP call, 1000 calls is about $2, so the ceiling is a budget guard as much as a timeout guard.

Per-feature LLM routing. Model choice is resolved per AI feature — blog generation, hero imagery, rank insights — with a three-step fallback and capability metadata attached to each feature. That metadata is what stops a provider that can only run asynchronously from being routed to a synchronous consumer. Keys are decrypted inside the resolver and never logged or returned.

Cache-busting is part of the build contract. Vite hashes the entry filename, not just chunks, and PHP resolves it from the build manifest. A stable filename meant browsers served stale bytes that imported old chunk hashes, React loaded twice, and reconciliation crashed. A server-rendered skeleton covers the gap before the deferred module executes.

Two-tier cache invalidation. Saving a post purges that post's URLs. Zone-wide purge exists, but it lives behind an explicit button on the Tools page — never on a content hook.


Scale

PHP source455 files, 85 service classes
Data11 custom tables, 40 migrations, 5 post types
API2 namespaces, 28 controllers, 248 route registrations
Admin26-route React SPA, 162 components and hooks
Automation21 recurring jobs, 241 MCP tools
Integrations20 connected services
Tests66 test files

Counts verified by file and registry enumeration at v3.299.0.


Stack

PHP 8.1 WordPress 6.5 React 18.3 TypeScript 5.6 Vite 6 TanStack Query 5 React Router 6 CodeMirror 6 Cloudflare Workers MySQL Playwright

Styling is hand-written CSS with design tokens — no utility framework.


The automation layer

CBM does not generate content in-process. It exposes the control-plane endpoints — claim a run, fetch prompts and topics, check a title for duplicates, upload media, publish, write a run log — and an automation platform drives them from outside.

That split is the reason one pipeline can serve every client site: adding a site is a configuration row on this side, and nothing changes on the other. It is also why improving a prompt never touches PHP.

PipelineWhat it drives
Blog Automation Engineresearch, draft, illustrate and publish — per site, every six hours
Social Distribution Networkthe published post out to five social platforms
Search Indexing Suitesubmission at a survivable rate, then proof of coverage
Local Rank Tracking APIlocal-pack position feeding the geo-grid heatmap

One architecture, three instances

CBM is not a one-off. The same skeleton — convention autoloader, singleton loader, a React SPA mounted inside wp-admin, a capability-gated admin REST namespace beside a public consumer namespace, and an in-plugin MCP server — has been instantiated three times for three different problem domains. The lineage is recorded in the code itself, not reconstructed after the fact: the commerce plugin's header names this one as its sibling, and several of its files carry explicit "ported from" and "mirrors" comments.

InstanceDomainShape
CBM (this project)Multi-site blog publishingmany client domains, one control plane, edge-rendered
Custom Store ManagementSingle-store commercecatalogue, orders, payments, returns, storefront assistant
EpsolDev ManagementAgency site backendthe smallest and cleanest, written last

The storefront that consumes the commerce API is Luxwearz.

Lessons travel between them in both directions. A cookie-scoping bug found on this plugin's CDN zone is cited by name in the commerce plugin's OAuth implementation, and the encryption helper in the agency backend is a direct, credited port of the commerce one. Where they diverge, that is documented too — this one relays AI work through an automation platform, the commerce plugin calls providers directly.

All projects