Case study · 202604 / 06
Multi-tenant restaurant CMS
Built a multi-tenant restaurant CMS on Wagtail and Next.js that serves 200+ websites, each with its own domain, theme and editors, from one deployment.
- Role
- Lead Engineer
- Year
- 2026
- Stack
- Python
- Django
- Wagtail
- Next.js
- React
- TypeScript
- PostgreSQL
- Cloudflare for SaaS
- Cloudflare R2
- Docker

Problem
Restaurants on our ordering platform need a website: their menu, deals, opening hours, photos, and a way to book a table or order online. Building and hosting a separate site for each one doesn’t scale. Every new restaurant would mean a new project, a new deploy and a new certificate, and every design fix would have to be repeated site by site.
The platform needed one SaaS CMS that serves every restaurant’s website. Each site still had to feel like the restaurant’s own: its own domain, branding, theme and content editors, with menus, deals and bookings coming live from the ordering platform instead of being typed in twice.
My role
I’m the lead engineer on the CMS. I designed it and wrote its codebase, which covers the tenancy model, the headless API, the Next.js app that renders every site, the themes, custom domains through Cloudflare, the integration with the ordering platform, the admin redesign, and the team tooling (CI, end-to-end tests, Docker images and docs).
The codebase is set up for a multi-developer team, with code owners, a PR template and an AI-assisted workflow: project rules, review agents and hooks for Claude Code that lint after every edit and block changes to the ordering platform’s repositories.
The CMS sits inside a larger ordering platform built by an engineering team of 15–20 developers. I own the CMS end to end: its architecture, its code and its contract with the rest of the platform. The websites never change the ordering platform directly. When they need something from it, such as CORS rules for booking or payment links that return to the restaurant’s own domain, I write it up as a request for the team that owns that API.
Architecture
- Wagtail CMS (Django 6.1, Wagtail 8, PostgreSQL 16). Each restaurant is a Wagtail Site with its own page tree, media collection and staff groups (Manager, Editor, View only). Editors build pages from 15 section types, including hero, menu highlights, full menu, deals, gallery, events, FAQ and testimonials.
- Headless JSON API. Read-only, public and unauthenticated, so it returns only live, public content. It maps a hostname to a restaurant’s settings and pages, and answers a domain check for TLS.
- Next.js 16 app (React 19, Tailwind 4). One process renders every restaurant. It reads the hostname, fetches that site from the API, and renders it with the restaurant’s theme, colours and font. There are 4 themes so far, and each one overrides components and CSS rather than branching inside shared code.
- Ordering platform integration. Menus and deals are fetched on the server and cached. Table booking, customer sign-in (an emailed code) and online ordering call the platform’s public API straight from the browser.
- Cloudflare for SaaS issues and renews a certificate for every custom domain, caches and absorbs attacks, and nginx passes traffic to Next.js or Wagtail. Uploads can live on Cloudflare R2 and load from the edge. Both apps ship as Docker images with liveness and readiness checks.
Key decisions & tradeoffs
Route by hostname, not by deploy. The restaurant is resolved only from the request hostname: its primary domain or an extra domain such as the platform subdomain. Adding a restaurant or connecting a custom domain is a data change in the admin. There’s no build, deploy or restart, and the CMS registers the domain with Cloudflare automatically. A sync command runs every ten minutes, refreshes certificate status and retries failures. The tradeoff is one shared process for every site, so tenant isolation has to be airtight. Page lookups stay inside that site’s tree, unknown hosts get a 404 in production, and every change to permissions needs an isolation test.
Headless, with one schema for both sides. Wagtail stays the editing tool, and a separate Next.js app does the rendering. To stop the two drifting apart, the API serialises sections generically from their block definitions, and a management command generates the matching TypeScript types from the same blocks. CI fails if the generated file is stale, so a schema change ships as a migration, the regenerated types and the component in one PR.
Short caches keyed by hostname, and soft 404s. Next.js caches CMS responses for 30 seconds, so a published change shows up within half a minute without a purge step. Next.js only caches successful responses, so the API answers “not found” as a cacheable 200 {"not_found": true}. Without that, a page that had since been unpublished could keep being served from cache.
Let the ordering platform fail without taking the site down. Menus are cached for five minutes and deals for one, so the platform sees about one request per restaurant per window however busy the site is. If a call fails, that section is left out and the rest of the page still renders. The platform also isn’t part of the website’s readiness check. Booking calls go from the browser because the platform rate-limits per IP.
Themes override; shared components stay generic. A theme supplies its own header, footer, heroes or section overrides plus a stylesheet. Shared components never check which theme is active, so adding a look for a group of restaurants doesn’t touch anyone else’s site.
Outcome & metrics
-
200+ restaurant websites served from one SaaS CMS.
-
New restaurants and custom domains go live without a deploy, with HTTPS issued automatically.
-
15 section types and 4 themes that editors combine without a developer.
-
135 Django tests (including tenant-isolation tests), 66 Vitest tests, and Playwright end-to-end tests for booking, ordering and tenancy. All of them run in CI with migration, generated-type and Docker health checks.
-
Launching a restaurant’s website is now an admin task, not an engineering ticket: create the site, pick a theme, connect the domain, and it’s live.
-
A design fix lands once, in a shared component or a theme, and reaches every site that uses it.
-
If the ordering platform has a bad minute, restaurant websites stay up and simply leave out the affected section.
What I’d do differently
- Build preview in from day one. Because Wagtail renders no public HTML, its built-in preview is switched off rather than left showing a broken page. Editors publish to see their changes, and a 30-second cache makes that feel quick, but a draft view rendered by the real Next.js theme belongs in the first version, not on the roadmap.
- Settle cross-team contracts before they block launches. Booking calls the ordering platform straight from the browser, so every new custom domain needs that platform’s CORS settings to allow it. I wrote the request for an allow-by-rule approach after custom domains already worked without a deploy. Agreeing it up front would have kept a manual step from sneaking back into an otherwise self-service launch.
- Generate the whole API contract, not just the sections. Section types are generated from the Wagtail blocks and checked in CI, but the site, profile and page types are still written by hand. Generating those too, from an OpenAPI schema, would close the last gap where the two apps can drift.