Custom CMS with AI

· 7 min read

A custom headless CMS and content dashboard for managing websites, pages, translations and media from a single interface — with AI connected to the content workflow over MCP.

A custom headless CMS and content dashboard for managing websites, pages, translations and media from a single interface — with AI connected to the content workflow over MCP.

At a glance

Problem
A roster of client websites, each on its own CMS installation, each needing its own updates — and each exposing a login form, a database and a PHP runtime on the client’s public domain.
What was built
One multi-tenant content dashboard behind every site: a block-based page builder, translations, media, menus and redirects, with each public site published as a static build.
My role
Primary author — the content model, the block and collection engines, the panel, the REST and MCP surfaces, provisioning and the frontend core package.
What makes it interesting
The CMS is exposed over MCP, so the same content can be edited in the panel or written straight from a terminal — by a person or by an AI tool, on the same permissions either way.
Result
One codebase to patch instead of many, no CMS on any client domain, and AI as part of the content workflow rather than a separate chat window.

Most of what a company calls “the website” is really two things: the site visitors see, and the place where someone sits down to change it. This project is the second one — a content dashboard, built from scratch, that runs a whole roster of client websites from a single interface.

It is commercial work, so the client and the product name stay out of this write-up. Everything below is about what the system does and how it is put together.

The problem it replaces

The default answer to “we need a site the client can edit” is one WordPress per client. It works right up until you have a dozen of them. Then every site is its own installation with its own core, theme and plugin tree to patch; every site has a login page sitting on the client’s own public domain; and every site has a database and a PHP runtime exposed to anyone who goes looking. Nobody has time to keep twelve of those current, so they quietly drift until something breaks or gets exploited.

I know what that looks like from the other side — the WordPress rescue work that starts after an outdated plugin lets someone in.

One dashboard, many static sites
One CMS install per client
01One codebase to patch — a security update ships once and covers every site.
02Nothing to update on the client's side, and no admin login to forget about.
03The public site is a static build: no database, no PHP, no admin panel on the client’s domain.
04No third-party plugin ecosystem — every content type is first-party code.
05One deploy pipeline, exercised on every release rather than once a year.
01N installations, each with its own core, theme and plugin updates.
02The client is expected to notice update nags and act on them.
03A live CMS, a database and a login form on every public domain.
04Plugins whose code and update cadence nobody controls.
05N deploy paths, most of them manual, all of them drifting.

What the dashboard actually lets a person do

Before any of the technology matters: this is a place where a non-technical person opens a page, changes it, and publishes it.

In the dashboard
  • Build a page from blocks Pick a hero, a feature list, a pricing table, an FAQ, a gallery, a contact panel — drag them into order and fill in the fields. No HTML, no shortcodes.
  • Keep every language in sync Each page holds one version per language, and a completeness screen shows exactly which pages are still missing a translation — including their SEO fields.
  • Upload an image once Web-optimised versions are generated in the background, so an editor uploading a 4 MB phone photo does not quietly make the site slow.
  • Run collections, not just pages Blog posts, case studies, services, pricing plans, testimonials, glossary entries — each is a collection with its own fields, listing page and categories.
  • Own the plumbing Menus, redirects, 404 reports, sitemap and hreflang, social preview images — the things that decide whether a site is findable, in the same panel as the content.
  • Give people the right keys Three roles, and an author can be granted exactly the collections they work on. Someone hired to write blog posts never sees the pricing page.
The dashboard's Pages list — 42 pages filtered by type (page, home, pricing, blog, glossary, portfolio, section), each row showing name, type, section, active state and last update

The page builder

A page is not a blob of HTML. It is an ordered list of typed blocks, stored per language, and each block knows its own fields. There are 32 block types today — hero, rich content, features, testimonials, pricing table, FAQ, gallery, team, opening hours, map, contact, video, tabs, carousel and the rest.

The block palette open in the page editor — every block type listed with a plain-language description of what it does and when to reach for it: Hero, Rich content, Paragraph, Button and the rest of the registry

Two things about that list matter more than its length:

  • Adding a block type is a config entry, not a new codebase. The palette an editor sees, the description beside each type, and the fields inside the block all come from one config file. The frontend then registers a component for that block name and it is done.
  • A capability shared by many blocks is declared once, not copied into each. The background band, the alignment, the reveal animation and the anchor id live in one definition, and a block simply states which of them it supports — 26 of the 32 do.
  • Every block gets a stable id the first time it is saved. That is what makes it possible to patch one block from the outside — by an API call, or by an AI tool — without touching the rest of the page.

The editor keeps an outline of the page beside it, one row per block with a line of its actual content, so a long page stays navigable instead of turning into a wall of collapsed panels.

The block editor on a service page: a structure outline down the left listing its six blocks with a line of their real content each (Hero, Testimonials, Rich content, Features / steps with 6 steps, Pricing table with 6 plans, CTA banner), the same blocks as reorderable rows in the middle, and Publish, author and OG image in the right sidebar

New content types without new code

The other half of the content engine is collections. A site that needs “case studies” or “opening hours per branch” or “pricing plans, but three separate sets of them” does not get a new module written for it. A collection is defined in the panel — pick the item type, switch the fields on, choose an icon — and it appears in the sidebar with its own editing screen, its own categories and its own API endpoint.

Each client also gets to have only the collections they actually use. Everything else stays switched off and out of their sidebar.

Sections screen — ten collection types as cards (Blog, Glossary, Portfolio, Services, Plans, Testimonials and four plan variants), each with an item count, an enable checkbox and an Edit fields link; two are active

Translations you can see the state of

Multilingual content usually fails quietly: someone adds an English page, nobody adds the Polish one, and the gap is only discovered months later by a visitor. The panel keeps a running count instead — how many pages are translated per language, which ones are missing, and whether the SEO title and description were filled in as well as the body.

Translation completeness screen — 91.9% translated, 34 of 37 pages, SEO 21 of 37, a warning about 3 missing page/locale combinations, and a table marking each page’s translation and SEO state

The part I find most interesting: AI is a first-class client of the CMS

Everything the dashboard can read, the CMS also exposes twice — once as a REST API and once as an MCP server, behind the same authentication. MCP is the protocol AI assistants use to talk to outside systems, so the second surface means an AI tool can work with the content directly instead of being handed copy-pasted text.

There are 82 tools on that surface today: list and read pages, search content, read the block schema, create a page, edit a single block by its id, set metadata, manage categories and menus and redirects, upload media, trigger a rebuild. A client can connect Claude to their own dashboard with their own panel login — OAuth with PKCE, not a token pasted into a config file — and then ask it to draft a page, fix the pages missing a description, or translate a section, with the changes landing in the CMS rather than in a chat window.

Two doors into the same content

The dashboard is one way in. The terminal is the other. Because MCP is a protocol rather than a feature of one app, the same tools that answer a chat window also answer Claude Code — so a developer can stay where they already are and write to the CMS from the command line: create the page, fill its blocks, set the metadata, trigger the rebuild.

That is not a demo path. The copy on the sites running on this system went in from a terminal, not by clicking through the panel. Writing twelve pages is a different job when you can hand over a brief and a structure instead of opening twelve forms.

Both doors lead to the same content, the same validation and the same permissions. The person who edits the About page once a quarter gets a panel that asks nothing of them; the developer who is setting up a whole site gets a command line. Neither has to pretend to be the other.

The guardrails were the interesting engineering problem, not the tools:

  • A credential is never wider than its owner Every gated tool checks twice: the person holds the permission, and the credential carries the matching ability. A read-only credential cannot queue a deploy or spend a third party’s quota.
  • Scoped to one client, always The credential’s owner decides which client’s content it sees. There is no client-id parameter for a caller to change, so there is no cross-tenant read to get wrong.
  • Rate limits with teeth Tools that spend real money — deploys, release tags, third-party API quota — share a tighter allowance than the read tools.
  • A written contract to read The block format has a canonical spec, and the frontend’s types are generated from the backend’s own schema. An AI tool editing a page is working from the same document a developer would.
Access credentials screen — two active credentials with creation and last-used dates and a Revoke button each, and a form to mint a new one with per-ability checkboxes (Read, Write, Deploy, Integrations, Cross-tenant, All abilities) and an expiry, under the line: a token can only ever do less than its owner, never more

From an edit to a live page

The publishing path
  1. Step 1

    Someone edits

    An editor reorders blocks and changes copy in the panel — or an AI tool patches one block through MCP. Same data, same validation.

  2. Step 2

    It is saved as structured content

    Blocks are stored per language as typed JSON, each with a stable id. Nothing is stored as rendered HTML, so the same content can be rendered any way later.

  3. Step 3

    Publish queues a build

    The panel says plainly when the live site is behind the content, and one button starts the rebuild.

  4. Step 4

    The site asks the API for its pages

    The static build authenticates with its own credential and pulls the fully resolved pages — blocks, references and all — in one pass.

  5. Step 5

    The new build goes live

    Editors watch it go queued → building → released, and the previous release stays around to switch back to.

A rebuild takes 10 to 20 seconds. That number is what makes “just publish it” a reasonable thing to say to a client rather than a scheduling conversation — and the panel keeps the history, so any earlier release is one button away.

Frontend Deploys screen — the latest build completed in 13 seconds, a list of retained releases with the live one flagged green and an Activate button to roll back to any earlier one, and a chart of build time in seconds across the last six deploys, all between 11 and 19 seconds

What visitors get at the end of that is a static site: HTML, CSS and images on a CDN. No database query, no PHP process, nothing to log into. The content lives in the dashboard; the public site is a snapshot of it.

Because the contract between the two is plain JSON over HTTP, the frontend is not locked in either. Astro renders it today; a Next.js or Nuxt or SvelteKit site would consume exactly the same endpoints without the backend changing at all.

Onboarding a new site

Adding a client is one form. Everything after it runs in the background as a chain of steps that can each be retried on their own: create the site’s repository, create the web app on the server, wire up the git deploy, issue the certificate. The panel shows which step it is on rather than a single pass/fail, and a failed run can be restarted without producing a duplicate of anything it already created.

My role

I am the primary author of the system: the multi-tenant content model, the block and collection engines, the Filament panel, the REST and MCP surfaces, the provisioning chain, the media pipeline, and the Astro core package the client sites are built from. Around 1,900 commits went in over the first ten weeks, most of them mine, alongside a small team and code review on every pull request.

Result

  • One backend, many sites A security update, a new block type or a new panel feature reaches every site at once instead of being repeated per installation.
  • Nothing to attack on the public domain No CMS, no database and no login form on a client’s own site — only a static build.
  • AI in the workflow, not beside it Content can be drafted, corrected and published by an AI assistant working directly against the CMS, under the same permissions as a person.

Technologies

Laravel

The application framework behind the whole backend — the content model, the queue, the API, provisioning and deploys.

Filament

The admin layer the dashboard is built on. The page builder is its Builder component driven entirely by config.

MariaDB

Content, translations, media metadata and per-client settings, with every query scoped to one tenant.

Astro + TypeScript

The static frontends. Their block types are generated from the backend schema, so the two cannot drift apart.

REST API

The build-time read surface, documented with OpenAPI and authenticated per client with bearer credentials.

MCP

82 tools mirroring the API for AI assistants, with per-credential abilities and rate limits on the expensive ones.

Back to portfolio

Related posts

Read more