Meridian handbook
Templates

Multisite templates

How several sites in one project share templates through an ordered chain of template roots, override a component, a block or a whole file for one site, extend their own name across the chain, and detect copies that have drifted.

One project can serve several sites, each with its own domains, locales, routes and look. Their templates live side by side under templates/ and resolve along an ordered chain of roots per site: the first root that holds a file wins. Shared templates go in _shared/, and a site overrides only what differs, as a component, a block or a whole file.

A minimal example

Two sites, county and town. The town shares everything with the county except its page header block:

templates/
  _shared/
    base.html
    page/entry.html
    components/teaser/
  town/
    page/entry.html
{# templates/town/page/entry.html #}
{% extends "page/entry.html" %}

{% block hero %}
  <div class="harbour-banner">{{ t("town.banner") }}</div>
{% endblock %}

{% extends "page/entry.html" %} inside town/page/entry.html does not resolve to itself: it resolves to the next page/entry.html along the town’s chain, here _shared/page/entry.html. The town’s page is the shared page with one block replaced.

Check what a site resolves:

engine-dmo describe template page/entry.html --site town
page/entry.html on site town resolves to town/page/entry.html (chain town, _shared)
  extends page/entry.html as _shared/page/entry.html
renders page (entry)

The chain

Each site’s chain is [<site key>, _shared] unless its site.yaml sets one:

# config/sites/chipping-norton/site.yaml
templates:
  roots: [chipping-norton, towns, _shared]
KeyDefaultMeaning
templates.roots[<site key>, _shared]Directories under the template directory, searched in order
templates.allow_unescapedfalseAllow the safe filter in this site’s templates

The template directory itself is template.dir in engine.yaml (default templates).

For three town sites that share a towns/ root, a request on the town of Chipping Norton looks for a name in its own root, then in towns/, then in _shared/, and renders the first file it finds; a file that extends its own name continues down the same chain:

flowchart LR
  N["page/entry.html<br/>on site chipping-norton"] --> A{"templates/chipping-norton/<br/>has it?"}
  A -- yes --> UA["Render it"]
  A -- no --> B{"templates/towns/<br/>has it?"}
  B -- yes --> UB["Render it"]
  B -- no --> C{"templates/_shared/<br/>has it?"}
  C -- yes --> UC["Render it"]
  C -- no --> X["Not found: check reports<br/>the missing template"]
  UA -.->|"extends page/entry.html"| B
  • First match wins. A name such as listing/entry.html, base.html, partials/footer.html or components/teaser/teaser.html is looked up in each root in turn.
  • _shared is a convention, not a keyword. A site whose chain is [business-site] alone shares nothing.
  • An intermediate root groups sites that resemble each other. Three town sites with roots: [<town>, towns, _shared] share towns/ without copying a file. Inserting a root later changes nothing that already exists, because no file names the chain.
  • Every root templates.roots names must be a directory under the template directory; engine-dmo check reports one that is not. With the default chain, a site needs no directory of its own until it overrides something.

engine-dmo assets manifest prints every site’s chain:

site town
  templates  town → towns → _shared

Extending across the chain

A template that extends or includes its own name resolves to the next file of that name further along the chain, never to itself:

{# templates/towns/base.html: every town wraps the shared layout #}
{% extends "base.html" %}
{% block body %}<div class="town-shell">{{ block.Super }}</div>{% endblock %}

{{ block.Super }} prints the parent’s content of the block. To skip a layer deliberately, name the root: {% extends "_shared/page/entry.html" %}.

The cheapest override that works

Stop at the first rung that solves the problem.

RungWhenHow
1. TokensA different logo, colours, type or spacingNo template change. Each site’s stylesheet sets its own CSS custom properties, and the shared templates and components read only the properties
2. A componentOne component looks different on one siteAdd components/<name>/ to the site’s root, with markup and optionally a contract that takes the same inputs (Components)
3. A blockOne region of a page differsExtend your own name and override the block, as above
4. A whole fileThe page is genuinely differentCopy it with --from-shared (below), so drift is reported
5. A different chainThe site is genuinely differentSet templates.roots

A site-only type (one declared under config/sites/<key>/schema/) has its templates in that site’s root and nowhere else.

Copies and divergence

When a site needs its own copy of a whole template, make it with the scaffold, which records where it came from:

engine-dmo new template article/entry.html --site town --from-shared

The copy’s first line names its source and the source’s content hash:

{# meridian:copied-from _shared/article/entry.html sha256=951fbf0b... on 2026-11-02 #}

When the shared original changes, or disappears, engine-dmo check warns at that line:

town/article/entry.html:1:1: warning: copied from _shared/article/entry.html on 2026-11-02, which has changed since
      = review the copy against _shared/article/entry.html, or copy it again with: engine-dmo new template article/entry.html --site town --from-shared --force

It is a warning, never an error, since a copy that differs is sometimes right. Remove the header line once the copy is meant to stand alone. engine-dmo check --strict turns the warning into an error for a CI gate that should insist on review.

--from-shared copies the template the site resolves today. Without it, engine-dmo new template <type> writes a starter for a type, into the shared root, or into a site’s root with --site.

What resolves along the chain

NameResolved along the chain
Page templates (<type>/entry.html, <type>/index.html, <type>/landing.html)Yes
base.html and every {% extends %}, {% include %} and {% import %}Yes
Components (components/<name>/) and their contractsYes
Block templates (blocks/<block>.html, rendered by {% blocks %})Yes
Error pages (errors/404.html, errors/500.html)Yes
Endpoint response templates (respond.fragment, on_error)Yes
Static files under public/Along a parallel chain: public/<site>/, public/_shared/, then public/

Every check runs per site: a shared template is checked once for each site whose chain reaches it, against that site’s types, fields, routes and components, so a shared template that uses a field one site lacks is reported for that site.

The same applies to anything a site declares. With engine-dmo, a shared template that calls promoted('home-feature') fails the check on every site sharing it that does not declare the slot home-feature; there is no way to mark the call optional. Declare the slot on every such site, or move the call into the template roots of the sites that have it.

A directory no site’s chain names is never rendered. With engine-dmo, engine-dmo portal scaffold without --site writes templates/portal/, which is on no chain by default; pass --site <key> so the files land where the site’s portal reads them.

Site-specific values without a template

Prefer configuration over a template fork for small differences:

  • settings in site.yaml is free-form and read as settings.<key> in templates, for a phone number, a sister site’s key or a feature switch.
  • site gives the site’s key, name, shortName, organisation, baseUrl, country, timezone, defaultCurrency, domains, embedOnly and robots.
  • sites lists every site (key, name, baseUrl), present only when the project has more than one.
  • Strings are per locale, not per site; a site-specific phrase belongs in settings or its own string id.
  • Image transforms can be replaced per site in config/sites/<key>/images.yaml (Images).
# config/sites/town/site.yaml
settings:
  sister: { key: county, name: "Visit the county" }
<a href="{{ siteUrl('/', site=settings.sister.key, locale=locale.code) }}">{{ settings.sister.name }}</a>

siteUrl() keeps the visitor’s language where the other site enables it, and that site’s default otherwise (Links and URLs). A type key in place of the path (siteUrl('listing', site=...)) lands on the equivalent section rather than the home page.

Styles and scripts per site

Each site has its own bundle. With the Vite extension, assets.css('main') and assets.js('main') print the site’s own build: src/entries/<site>.js by convention, or what assets.bundles in its site.yaml names. The meridian() Vite plugin adds to each site’s bundle the CSS and JS of the components that resolve for that site, so an overridden component’s styles reach only its site. See Frontend build.