Background
The agency I work for builds marketing sites for orthodontic and dental practices. Most of the sites we build today start from one of a few in-house templates and are customized for the client. Over the years this produced a large number of small sites, each in its own repository with its own deployment.
The fleet was becoming unmaintainable, with hours and resources spent updating the same thing across multiple sites. The idea of a monorepo had been floated, but no one knew how or where to start, so I built a proof of concept to help get the ball rolling.
The problem
The main template lineage contained 115 repositories. 107 of them were two major versions behind, declared as a floating version range, which meant every repository’s lockfile was its own snapshot. There were, in effect, up to 107 different resolved versions of the same site.
Those 107 repositories were also pinned to a Node runtime that had been end-of-life for over two years. Only three repositories declared a Node version at all. The constraint came from a native Sass dependency that would not compile on anything newer, so the runtime was enforced by build failure rather than by configuration.
The newer generation of the template had a subtler problem. Its seven client forks kept nearly identical package manifests, but a file-level diff showed that each fork had rewritten between 14% and 56% of the shared component library, and the percentage grew with the age of the fork. The oldest and newest forks, seven months apart, already disagreed on 42% of their components.
A second template lineage showed the same pattern. Across 25 repositories, only 14% of components were identical everywhere. The average component existed in three to four distinct versions, and one form component existed in twelve.
The underlying cause is that in a copy-based model, branding a client requires editing shared code. A dependency vulnerability therefore meant up to 107 separate patches on an unsupported runtime, and that is for one template. Counting the other templates and our fully custom builds, roughly 370 live client sites share this stack, each with its own repository, and the same patch would need to be applied to every one of them. In most cases those sites would not be patched but would eventually be rebuilt or abandoned.
Constraints
The sites team had its own backlog and could not absorb a migration that interrupted client work. The idea needed to be concrete enough to react to, so I built something running rather than writing a proposal.
Content for the existing sites lived in a headless WordPress install per site. Whatever replaced the templates had to retire that dependency rather than build on it.
I had a few weeks of part-time attention for this since I work on a different team and this was not my primary project.
Approach
Sites were drifting because customizing a site meant editing code that was supposed to be shared. The fix was to move all of the code into one repository that every site builds from, and to make each site nothing more than a folder of configuration and content. When a client needs something the configuration can’t express, the change is made in the shared code as a new option on an existing component, and every other site picks it up on its next build.
I set it up this way, rather than publishing the shared code as a package for each site to install, because a package still leaves each site free to stay on an old version or to work around the library locally. If a site folder has no place to put code, a site can’t do either of those things.
What I built
A working prototype with a reference site and three real client sites ported into it. One of the client sites was deployed to production infrastructure so that the deployment process was tested along with the code.
The repository is split into three directories. packages/ holds code shared by every site regardless of design. templates/ holds the values and styles that define one design family. sites/ holds one client’s configuration, content, and images, and nothing else. The directory you are editing tells you how many sites your change will reach.
Theming works in layers. A shared package defines which theme values exist, such as the color roles, font families, and border radii a site can set. Each template fills in defaults for its design, and a client site can override any of them in its configuration file. The configuration is validated at build time, so a typo in a color name fails the build instead of silently falling back.
Page sections are looked up by template. A site’s configuration lists the sections on each page by name, and the build finds the version of that section written for the site’s template. Supporting a new design for an existing section means adding one file.
Dependency versions are declared in one place and shared by every site. The prototype uses about ten packages where the previous template used 29. I kept the list short on purpose: libraries for the framework, animation, validation, image processing, and testing, and our own code for things like carousels, accordions, and forms, built on native HTML elements.
Deployment is selective. The build tool reports which sites were actually affected by a change, and the deploy step syncs only those. Editing one site’s content deploys one site. Editing a shared component deploys all of them.
Each template has a reference site with screenshot tests, so a change to shared code that alters how a design renders fails the build before it reaches a client.
I wrote the plan, the decisions, and the audit data into the repository next to the code, so that someone could read through it and evaluate it without me walking them through it.
Results
The sites team adopted the model, and their catalog of sites is currently being moved onto the new platform. The production platform is built in its own repository by that team and follows the prototype’s structure: the three tiers, the rule that sites contain no code, the theming layers, and the single set of dependency versions. I review and advise but am not writing most of the production code.
The measurement that would show the full effect, patch time across the fleet, belongs to the production platform and is not yet available. What the prototype demonstrates is that upgrading the framework across every site in the workspace is a one-line change in a single pull request.
Looking back
I would do the audit earlier. It took two days and did more to move the decision than any discussion of architecture. I would also have ported a difficult client site first rather than a clean one, because the question the other team most wanted answered was how expensive a messy port would be, and the prototype did not answer it.