Back to Insights
Jan 23, 2026Simon Bromfield5 min read

Building a Multi-Tenant CMS Platform from Scratch

Building a Multi-Tenant CMS Platform from Scratch

When I started building websites for clients, the content management problem became obvious quickly. Spinning up a WordPress instance for each client means multiple databases to backup, multiple systems to patch, multiple security surfaces to worry about. I wanted one system to manage them all.

What I Needed

The requirements were straightforward: a single admin dashboard to manage all client sites, real-time content previews so clients see changes instantly, validated content structures to prevent broken layouts, and SEO handled automatically. No client should need to think about sitemaps or structured data.

How It's Built

The platform is a monorepo with three main applications:

CMS Admin (Next.js 14) — The dashboard where I manage all client content. Page editing, media uploads, site settings.

Backend API (Express) — REST endpoints for data, Socket.io for real-time updates, JWT auth for the admin, API keys for client sites.

Template Site (Next.js 16) — The actual client websites. Server-rendered for SEO, with live updates via WebSocket when content changes.

Using npm workspaces means shared code between all three. Validation schemas, utility functions, type definitions — written once, used everywhere. When I update how a section is validated, both the admin forms and the backend checks use the same logic.

Real-Time Updates

This was the interesting technical challenge. When I edit content in the admin, connected client sites should update immediately — no page refresh, no redeploy.

Each client site connects to a Socket.io room based on its site ID. When content changes, the backend emits an update to that room. The client site has a React hook listening for updates:

export function useRealtimeContent(initialContent, pageId) {
  const [content, setContent] = useState(initialContent);

useEffect(() => { const socket = io(process.env.NEXT_PUBLIC_CMS_URL, { auth: { siteId: process.env.NEXT_PUBLIC_SITE_ID, apiKey: process.env.NEXT_PUBLIC_API_KEY } });

socket.on('content:updated', (data) => { if (data.pageId === pageId) { setContent(data.sections); } });

return () => socket.disconnect(); }, [pageId]);

return content; }

Changes appear on the live site within milliseconds. Clients love it.

Schema-Driven Content

Every section type has a defined schema. A hero section knows it needs a headline, optional subheadline, background image, and CTA. A testimonials section knows it needs an array of quotes with names and titles.

This gives me three things: validation (content is always structurally correct), auto-generated forms (the admin UI renders controls from schemas), and type safety (frontend components know exactly what data they're getting).

export const sectionSchemas = {
  hero: {
    name: 'Hero Section',
    fields: {
      headline: { type: 'text', required: true, maxLength: 100 },
      subheadline: { type: 'text', maxLength: 200 },
      backgroundImage: { type: 'image' },
      ctaText: { type: 'text', maxLength: 30 },
      ctaLink: { type: 'text' }
    }
  }
};

The admin dashboard reads these schemas and generates appropriate form controls automatically. Add a new section type, get the form for free.

Database Design

Multi-tenancy is baked into the data model. Users own Clients, Clients own Sites, Sites own Pages and Posts. Every query is scoped.

Pages store their sections as JSON, which provides flexibility without requiring database migrations for each new section type. The trade-off is you lose relational queries on section content, but for this use case that's fine — sections are always fetched with their parent page.

SEO Out of the Box

Every site automatically gets robots.txt, XML sitemaps, and JSON-LD structured data. This is handled at the template site level using Next.js metadata APIs. When a page is created or updated, the sitemap regenerates. When site settings change, the structured data updates.

Clients don't think about SEO infrastructure. It just works.

Rendering Strategy

The template site uses hybrid rendering: SSR for initial page loads (fast first paint, SEO-friendly), client-side updates via Socket.io for live editing, and ISR for blog posts that don't need real-time updates.

export const revalidate = 3600; // Revalidate every hour

export default async function Page({ params }) { const content = await fetchPageContent(params.slug);

return ( ); }

What I Learned

Validate at the boundary. Checking content against schemas on both write and render catches issues early and provides clear error messages.

WebSockets are worth the complexity. The real-time preview alone justified the Socket.io integration.

Monorepos work well for related applications. Sharing code between admin, backend, and template site eliminates drift and reduces bugs.

Build enterprise features from day one. Every client site is SEO-ready without extra configuration.

Tech Stack

| Layer | Technology | |-------|------------| | Admin Frontend | Next.js 14, Tailwind CSS, TanStack Query | | Backend API | Express.js, Prisma ORM, Socket.io | | Template Site | Next.js 16, SSR/ISR, Socket.io Client | | Database | SQLite (dev), PostgreSQL (prod) | | Validation | Zod, Custom schema system | | Auth | JWT (admin), API Keys (sites) |

What's Next

The platform is live and managing several client sites. On the roadmap: visual drag-and-drop page building, version history with rollback, multi-user collaboration with roles, and edge deployment via Cloudflare Workers.

If you're building something similar or want to discuss the architecture, get in touch.

Simon Bromfield Full Stack Developer

Enjoyed this article?

Check out more insights or get in touch to start your project.