Theme Resolution¶
Customer-facing templates live under a theme prefix — templates/bootstrap/product.html,
templates/nova/product.html. Controllers never name the theme; they return the theme-relative
view name ("product", "forgot-password/forgotPassword") and a chain of Thymeleaf resolvers
inserts the theme.
The resolver chain¶
BadgerSpringResourceTemplateResolver (web) and EmailTemplateResolver (emails) both run three
legs, each with checkExistence=true so a miss falls through to the next:
| Order | Leg | Resolves |
|---|---|---|
| 0 | override | <templatePath>/X — only when the theme is an override theme |
| 1 | base | <baseThemeTemplatePath>/X (override themes) or <templatePath>/X (non-override) |
| 2 | default-theme | bootstrap/X, whatever the theme is |
The theme comes from siteContext.templateTheme (a ThemeContext): templatePath,
baseThemeTemplatePath and the overrideTheme flag. The optional family and sortOrder fields
only affect how the admin picker groups and orders themes, not resolution; see
Choosing a Theme.
For an override theme this gives the documented "nova, then bootstrap" behaviour from legs 0 and 1 alone. Leg 2 is the safety net described below.
The web resolver reads the theme from the request-scoped SiteContextProducer. The email resolver
cannot — the worker renders emails with no HTTP request in scope — so it takes the theme from
Thymeleaf templateResolutionAttributes supplied per render call. Those attributes are also part
of the cache key, so cached templates never bleed across tenants
(thymeleaf#380).
Why the default-theme leg exists¶
A theme configured with overrideTheme = false has no baseThemeTemplatePath. Legs 0 and 1 then
both point at the theme's own path, so there is no fallback at all: any page the theme does not
implement fails to resolve, and Thymeleaf raises
TemplateInputException: Error resolving template [...], template might not exist
or might not be accessible by any of the configured Template Resolvers
which surfaces to the visitor as a 500. For emails it is worse — the render throws inside the send path and the message is simply never delivered.
Leg 2 degrades those to the default bootstrap theme instead. A theme only needs to ship the
templates it actually wants to restyle.
Known gap: Nova tenant data contradicts the documented setup¶
CLAUDE.md describes Nova as an override theme with bootstrap as its base. Tenant data in
practice does not always match — for example:
db.siteContext.findOne({siteId: "dukie"}).templateTheme
// { name: 'Nova', templatePath: 'nova', overrideTheme: false } // no baseThemeTemplatePath
With leg 2 in place this is no longer fatal, but it is still wrong: such a tenant silently renders the bootstrap version of every page Nova does have a template for further down the chain, rather than resolving through Nova's base-theme relationship as intended.
Setting overrideTheme: true and baseThemeTemplatePath: 'bootstrap' on Nova siteContext
documents is the real fix; a site upgrade record would correct existing tenants. Until then, treat
the templateTheme block as something to check when a tenant renders unexpectedly.
Adding a page to a theme¶
- Add
templates/<theme>/<path>.html. - Return the theme-relative name from the controller —
"forgot-password/forgotPassword", never"nova/forgot-password/forgotPassword". A leading slash is stripped, but omit it. - Reference fragments theme-relatively too:
~{fragment/head :: head(...)}. - If you do not add a theme-specific version, the chain falls back to
bootstrap— that is expected, not a bug.
Nova's standalone account pages (login, register, forgot/reset/change password) share
nova/fragment/auth-head.html, which carries the <head> and the auth design-system CSS so each
page does not repeat it.
Themes layered on another theme (Brock on Nova)¶
Any override theme can use another override theme as its base. Brock is overrideTheme: true with
baseThemeTemplatePath: nova, so a page resolves Brock → Nova → Bootstrap (legs 0, 1, 2). Two rules
come with that:
- A theme's fragment file shadows the base theme's file of the same name, for every caller.
When a Nova template does
~{fragment/icons :: ccVisa(...)}on a Brock site, it gets Brock'sfragment/icons.htmlif one exists — and fails if that file lacksccVisa. So either re-declare every fragment the base theme's templates use, or give the new theme's fragments a new file name (Brock's icons live infragment/brock-icons.htmlfor exactly this reason). - Asset paths come from the
ThemeContext, not the template folder. Brock'scssPath/jsPath/imagePathpoint at Nova's asset folders, so every Nova template and extension script it falls back to keeps loading its CSS/JS; Brock's ownbrock.css/brock.jsare linked by path from Brock's head.
Controllers must return theme-relative view names¶
CheckoutController, DemoModeController and LoginAndRegistrationController used to return
siteContext.getTemplateTheme().getTemplatePath() + "/page-9-3" (and /demoLogin, /registration).
The resolvers then looked for <theme>/<theme>/page-9-3, which never exists; the view only rendered
because Spring Boot's own default resolver found templates/<theme>/page-9-3.html directly. Any
theme without its own copy of those templates got a 500 on checkout. They now return the bare name,
like every other controller, so the chain above applies.
A th:if on the same element does not guard a th:insert¶
Thymeleaf processes attributes in precedence order, and fragment insertion comes first:
th:insert / th:replace are 100, th:each is 200, th:if / th:unless are 300,
th:with is 500. So on a single element the fragment's parameters are evaluated before any
th:if on that same element can remove it:
<!-- Broken: product.images[0].url is evaluated even when hasImage is false -->
<div th:if="${hasImage}"
th:insert="~{fragment/responsive-image :: responsiveImage(${path}, ${product.images[0].url}, '')}"></div>
A product with no images gives images == null, so that expression throws
EL1012E: Cannot index into a null value and the whole page 500s — not just the one card.
This took out every Brock listing on a demo tenant the first time the theme was switched on.
Put the guard on a wrapping th:block instead. A parent's th:if is resolved before its subtree is
processed, so nothing inside is evaluated:
<th:block th:if="${hasImage}">
<div th:insert="~{fragment/responsive-image :: responsiveImage(${path}, ${product.images[0].url}, '')}"></div>
</th:block>
Making the parameter itself null-safe (a ternary, as Nova's card does) also works. th:with on the
same element is fine to guard with th:if, because 300 runs before 500 — only fragment insertion is
affected.