Template Layouts — Two-Pass Rendering & Tenant Override
Layouts provide a shared HTML envelope (branding, header, footer) that wraps content
templates automatically. Content templates contain only the body — no DOCTYPE, no
<head>, no boilerplate.
How it works
Section titled “How it works”The renderer performs two-pass rendering when a layout is configured:
- Render content — the content template produces an HTML fragment
- Render layout — the layout template receives the fragment as
{{ body | raw }}and wraps it in the full HTML structure
Layout resolution chain
Section titled “Layout resolution chain”Layout is resolved in priority order:
TemplateDescriptor.LayoutName(database) — admin override per templateILayoutRegistry(code) — pattern-based defaults registered at startup- No layout — template renders standalone
Registering layouts (code-level)
Section titled “Registering layouts (code-level)”// All notification emails use the email layoutservices.AddTemplateLayout("Notifications.*", "Layout.Email");
// All billing templates use the document layoutservices.AddTemplateLayout("Billing.*", "Layout.Document");
// Exact match takes precedence over prefixservices.AddTemplateLayout("Billing.SpecialInvoice", "Layout.Custom");Patterns support exact names ("Billing.Invoice") and prefix wildcards ("Billing.*").
Exact matches always win. Among prefix matches, higher priority and longer prefix win.
Layout template example
Section titled “Layout template example”<!DOCTYPE html><html lang="{{ context.culture }}"><head> <meta charset="utf-8"> <title>{{ model.title }}</title></head><body> {{- if app.logo_url != "" }} <img src="{{ app.logo_url }}" alt="{{ app.name }}"> {{- end }} {{ body | raw }} {{- if app.base_url != "" }} <p><a href="{{ app.base_url }}">{{ app.name }}</a></p> {{- end }}</body></html>The content template is just the body:
<p>You have received a new notification of type<strong>{{ model.notification_type }}</strong>.</p>Admin override
Section titled “Admin override”An administrator can assign a specific layout to a template via the admin UI
(PUT /templates/{name} with layoutName field). This overrides the code-level
ILayoutRegistry default. Set layoutName to null to revert to the default.
Tenant override
Section titled “Tenant override”A tenant can override a layout by publishing their own version (e.g. Layout.Email)
to the database template store. StoreTemplateResolver (priority 100) wins over the
embedded default (priority -100). Multi-tenant query filters ensure isolation.
Safety guards
Section titled “Safety guards”- Layout is always terminal — the layout render pass never resolves a layout for itself, preventing infinite loops
- Graceful degradation — if the layout template cannot be resolved, a warning is logged and the content renders standalone
- Binary skip —
BinaryRenderedContent(Excel) skips layout wrapping