Skip to content

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

  1. Add templates/<theme>/<path>.html.
  2. Return the theme-relative name from the controller — "forgot-password/forgotPassword", never "nova/forgot-password/forgotPassword". A leading slash is stripped, but omit it.
  3. Reference fragments theme-relatively too: ~{fragment/head :: head(...)}.
  4. 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's fragment/icons.html if one exists — and fails if that file lacks ccVisa. 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 in fragment/brock-icons.html for exactly this reason).
  • Asset paths come from the ThemeContext, not the template folder. Brock's cssPath / jsPath / imagePath point at Nova's asset folders, so every Nova template and extension script it falls back to keeps loading its CSS/JS; Brock's own brock.css / brock.js are 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.