
Component composition without duplication in multisite Drupal
How to keep components coherent while supporting real variation across institutions. Patterns that work at scale.
I have been thinking for a year about how to build a multisite component library that does not end up as either “one library that handles nothing well” or “five separate libraries that share nothing.” The breakdown usually happens around year two, when the first institution asks for a legitimate variant that the library does not support, and the instinct is to fork the component rather than extend it.
The answer is not novel, but the execution is specific enough that it is worth walking through. It is also the place where most multisite platforms fail.
The architecture
The core insight is to separate base components from composition components. A base component is minimal and stable: a card with a title, excerpt, and image. Nothing more. No variants in the markup. A composition component is how you combine base components for a specific context: a featured-article hero is a card plus a call-to-action plus a background image, arranged for the homepage.
components/├── base/│ ├── card/│ │ ├── card.component.yml│ │ ├── card.twig│ │ └── card.scss│ ├── button/│ ├── image/│ └── badge/├── composition/│ ├── featured-article-hero/│ ├── news-card-with-tags/│ └── faculty-profile-teaser/└── site-specific/ ├── medical-school/ │ └── faculty-card-with-credentials/ └── law-school/ └── faculty-card-with-specialization/The base components are shared across all institutions. They do not vary. A card is a card everywhere. The composition components are where the institution-specific work lives. A featured-article hero at the medical school might include a badge for “research” or “clinical.” At the law school, it might include a specialization field.
The site-specific layer is for things that are genuinely unique — a component that only one institution uses. This layer is where drift lives. The rule is: if two institutions need the same thing, it moves up to composition. If one institution needs it, it stays site-specific.
Why this matters
Without this distinction, you get a base library that tries to handle every variation. A card component that might or might not have a badge, might or might not show the author, might display the metadata in different orders, ends up with a dozen optional props and unclear semantics. When a new institution joins and needs something slightly different, the card gets one more optional prop. By year three, the card is doing forty different things and nobody understands what it actually does.
With the distinction, a base card does one thing. Consistently. Everywhere. If an institution needs a card with a badge, that is not a variant of the card. It is a composition where a badge component sits next to a card component.
name: Carddescription: A card with a title, excerpt, and image.props: schema: type: object properties: title: { type: string } excerpt: { type: string } image: type: object properties: src: { type: string } alt: { type: string }This is the entire contract. A card receives exactly these props. It always renders the same structure. No variants, no optional fields, no escapes.
<div class="featured-article-hero"> <div class="featured-article-hero__content"> <div class="featured-article-hero__badge"> {% include '@component/badge' with { text: badge_text } %} </div> <div class="featured-article-hero__card"> {% include '@component/card' with { title: node.title, excerpt: excerpt, image: { src: image_src, alt: image_alt } } %} </div> </div> <div class="featured-article-hero__media"> {% include '@component/image' with { src: background_image } %} </div></div>The composition component describes how the pieces fit together. It is not trying to be a card that sometimes has a badge. It is a container that includes specific components in a specific arrangement.
How data flows through the layers
The challenge in a multisite platform is that data transformation happens at different layers. Drupal provides a node. The base component expects specific props. The composition component might transform those props further.
public function preprocessFeaturedArticleHero(&$variables) { $node = $variables['node'];
// Transform node data to card props $variables['card_props'] = [ 'title' => $node->getTitle(), 'excerpt' => $this->truncateBody($node->body->value, 150), 'image' => $this->processImage($node->field_image), ];
// Add hero-specific data $variables['badge_text'] = $this->getBadgeForType($node->getType()); $variables['background_image'] = $this->processImage($node->field_hero_background);}The preprocess function is still doing work, but it is now clearly structured: transform Drupal data into props for the base components, then add composition-specific data. An institution that needs a different badge system can override the getBadgeForType method. An institution that needs a different card transformation can override that layer. The base card itself is never touched.
What happens at the site-specific layer
Institutions will occasionally need something genuinely unique. The rule is: if it is unique to one institution, build it there. Do not add it to the shared library. Do not even add it to the composition layer. Build it in a site-specific directory and own the maintenance burden.
sites/├── medical-school/│ ├── components/│ │ └── faculty-card-with-credentials/│ │ ├── faculty-card-with-credentials.component.yml│ │ ├── faculty-card-with-credentials.twig│ │ └── faculty-card-with-credentials.scssThis site-specific component can use base components internally. It can even use composition components. But if no other institution uses it, it does not go into the main library. The burden of updating it when base components change stays with the site.
What this costs
This architecture is more work upfront. You are writing a composition component instead of adding a variant to an existing component. But the cost compounds in the other direction. Two years in, a shared library that has tried to handle variation is a tangle. A shared library built on base + composition + site-specific is still coherent.
The other cost is discipline. Every new feature is a decision about where it lives. Base components cannot change without updating composition components that depend on them. That is actually good — it makes dependencies explicit. But it requires thinking before you code.
The last cost is resisting the temptation to make the base component “just slightly more flexible.” Do not add an optional badge prop to the card because “it’s only one more field.” If you need a badge on a card, that is a composition challenge. Solve it in the composition layer, not by inflating the base.
I have watched multisite platforms for ten years, and the ones that are still coherent at scale are the ones that held this line. The ones that started with “we will have base variants” or “the card is flexible enough” are the ones that fractured.