Standalone Engine & Configuration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE (the latest release on Maven Central on 2026-10-07, published 2026-04-21) and Java 21 or newer, and it applies to Thymeleaf standalone: there is no Spring, no Spring Boot and no Spring MVC anywhere in the listings. Only org.thymeleaf:thymeleaf is on the classpath, plus an SLF4J binding and, for the servlet examples, a Jakarta Servlet container. Every listing was compiled and run in a scratch Maven project on JDK 26 (compiled for release 21), as a plain main method and as a jakarta.servlet.Filter served by embedded Jetty 12.0.40, and the output shown is the real output. The Spring-specific classes that replace most of what is described here (SpringTemplateEngine, SpringResourceTemplateResolver, ThymeleafViewResolver) are covered by the Spring Boot Auto-configuration & Properties and Spring MVC Integration pages.

Thymeleaf is a library, not a framework: it does not know what a request, a controller or a URL is. All it offers is an engine that takes a template name and a context and writes the result to a Writer or returns it as a String. Everything around that call (finding the template, finding the messages, caching the parsed template, converting values) is pluggable, and a Spring application merely plugs in Spring-aware implementations of the same extension points. Knowing the standalone configuration therefore pays off twice: it is the only way to use Thymeleaf in a command-line tool, a batch job that renders emails or reports, or a plain servlet application, and it is what Spring Boot’s auto-configuration builds for you in an MVC application.

In a hurry: what to use for what

I need to…​ Use Section

Render a template to a String in a main or a batch job

new TemplateEngine(), one resolver, engine.process(name, new Context(locale)).

The engine and process

Load templates from the classpath, a folder, a URL, a string or the servlet context

ClassLoaderTemplateResolver, FileTemplateResolver, UrlTemplateResolver, StringTemplateResolver, WebApplicationTemplateResolver.

Template resolvers

Let a folder of overrides win over classpath defaults

Two resolvers with setOrder, setResolvablePatterns and setCheckExistence(true).

Chaining resolvers: order, resolvable patterns, existence check

Render inside a servlet filter, with request parameters and session

JakartaServletWebApplication.buildApplication(servletContext), buildExchange(request, response), new WebContext(exchange, locale).

Contexts, WebContext and the 3.1 web abstraction

Load messages from a database or a map

A custom IMessageResolver (extend AbstractMessageResolver).

Message resolvers

Format values with the double-brace syntax

StandardDialect.setConversionService(...).

Conversion services

See why a template is slow, or what the engine was configured with

The loggers org.thymeleaf.TemplateEngine.CONFIG and .TIMER.

Logging

Pick up an edited template, or bound the cache

setCacheable(false), setCacheTTLMs, StandardCacheManager, clearTemplateCache().

The template cache

The dependency

Standalone Thymeleaf is a single artifact. Its own dependencies (OGNL, AttoParser, Unbescape and slf4j-api) arrive transitively, so a Maven build needs one coordinate, plus a logging binding of your choice so that SLF4J does not fall back to a no-op logger.

<dependency>
  <groupId>org.thymeleaf</groupId>
  <artifactId>thymeleaf</artifactId>
  <version>3.1.5.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.slf4j</groupId>
  <artifactId>slf4j-simple</artifactId>
  <version>2.0.20</version>
</dependency>

Use 3.1.5 or newer: the 3.1.4 and 3.1.5 releases of April 2026 carry fixes for the security advisories CVE-2026-41901, CVE-2026-40478 and CVE-2026-40477 (see the Thymeleaf GitHub advisories). The thymeleaf-spring6 artifact is the Spring bridge and is not needed here.

The engine and process

The central type is org.thymeleaf.ITemplateEngine; org.thymeleaf.TemplateEngine is its implementation in the core library. A usable engine needs exactly one thing: a template resolver. Everything else (dialect, message resolvers, cache manager) has a default. The engine is configured by setters, initialises itself lazily on the first process call, and after that rejects any configuration change with an IllegalStateException ("Template engine has already been initialized …​"). Configure it once, at start-up, and share the one instance: a configured engine is meant to be used by many threads at the same time, while a context holds the data of a single rendering and must not be shared.

Rendering a template to a String and to a Writer
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(true);

TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);

Context ctx = new Context(Locale.forLanguageTag("es"));
ctx.setVariable("name", "Ana");
ctx.setVariable("when", java.time.LocalDate.of(2026, 10, 7));

String html = engine.process("hello", ctx);                 // returns the result

java.io.StringWriter out = new java.io.StringWriter();
engine.process("hello", ctx, out);                          // writes to a Writer

With src/main/resources/templates/hello.html, hello.properties (hello.greeting=Hello, {0}!) and hello_es.properties (hello.greeting=¡Hola, {0}!) on the classpath, where the template is

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Hello</title></head>
<body>
  <p th:text="#{hello.greeting(${name})}">Hello, World!</p>
  <p th:text="${when}">2026-01-01</p>
</body>
</html>

the first call prints:

<!DOCTYPE html>
<html>
<head><title>Hello</title></head>
<body>
  <p>¡Hola, Ana!</p>
  <p>2026-10-07</p>
</body>
</html>

process comes in three families, all final on TemplateEngine:

Signature Meaning

String process(String template, IContext context)

Render the whole template and return it.

void process(String template, IContext context, Writer writer)

Render the whole template into the writer. Use this in a web layer, so that the output streams.

String process(String template, Set<String> templateSelectors, IContext context) and the Writer variant

Render only the parts matched by markup selectors, such as Set.of("p"). This is how fragments are rendered on their own.

process(TemplateSpec spec, IContext context) and the Writer variant

The same with a TemplateSpec that bundles the template name, selectors, an optional forced TemplateMode and template resolution attributes.

processThrottled(...)

Returns an IThrottledTemplateProcessor that produces the output in chunks, for reactive and back-pressured consumers.

Rendering only the paragraphs of the template above, with engine.process("hello", Set.of("p"), ctx), gives <p>¡Hola, Ana!</p><p>2026-10-07</p>: the markup selector syntax is the one of fragment expressions, and the fragments are cached under their own key (hello::[p]).

If no resolver can find the template, process throws org.thymeleaf.exceptions.TemplateInputException ("Error resolving template [missing], template might not exist or might not be accessible by any of the configured Template Resolvers"), and any failure during rendering is logged by the engine at ERROR level and rethrown as a TemplateProcessingException. A TemplateEngine that was given no resolver at all uses a StringTemplateResolver, so the template "name" you pass is then the template itself.

Template resolvers

A template resolver implements org.thymeleaf.templateresolver.ITemplateResolver. Its job is to turn a template name (and, for fragments, the name of the owner template) into a TemplateResolution: the resource to read, the template mode, the character encoding and the cache validity. Thymeleaf ships six implementations; writing your own means implementing ITemplateResolver or extending AbstractTemplateResolver.

Resolver Where templates come from

WebApplicationTemplateResolver

Resources of the web application, through an IWebApplication (the servlet context): getResourceAsStream("/WEB-INF/templates/home.html"). Needs a web context (an IWebContext) when processing. Built with new WebApplicationTemplateResolver(application).

ClassLoaderTemplateResolver

Class loader resources. new ClassLoaderTemplateResolver() uses the thread’s context class loader; new ClassLoaderTemplateResolver(ClassLoader) uses the one you give. The usual choice outside the web.

FileTemplateResolver

Files on the file system: the prefix plus the name is a path.

UrlTemplateResolver

A URL, including remote http/https ones: the prefix plus the name is opened with URL.openStream(). Its cache validity is computed differently from the other resolvers (it has its own computeValidity).

StringTemplateResolver

The template name is the template: engine.process("Dear [(${name})]", ctx). Not cacheable by default, because the "names" are arbitrary strings. It has no prefix, suffix or encoding, and it does not support decoupled logic.

DefaultTemplateResolver

Always returns the one text set with setTemplate(String), whatever name is asked for. Default mode HTML; its templates are always cacheable.

The first four extend AbstractConfigurableTemplateResolver and therefore share the same options, on top of the ones every resolver has (name, order, resolvable patterns, existence check, decoupled logic):

Option Effect Default

setPrefix, setSuffix

Wrapped around the template name to build the resource name: the name product/list with prefix /WEB-INF/templates/ and suffix .html is /WEB-INF/templates/product/list.html.

none

setForceSuffix(boolean)

Append the suffix even when the name already ends in a known extension (.html, .htm, .xhtml, .xml, .js, .json, .css, .rss, .atom, .txt). Since 3.0.6.

false

addTemplateAlias(alias, name), setTemplateAliases(Map)

Names that do not correspond to a resource name. An alias is applied before the prefix and suffix.

none

setCharacterEncoding

Encoding used to read the resource.

the resource default

setTemplateMode

HTML, XML, TEXT, JAVASCRIPT, CSS or RAW.

HTML

setForceTemplateMode(boolean)

Apply the configured mode even if the resource name ends in a known extension; otherwise the resolver may pick the mode from the extension. Since 3.0.6.

false

setHtmlTemplateModePatterns, setXmlTemplateModePatterns, setTextTemplateModePatterns, setJavaScriptTemplateModePatterns, setCSSTemplateModePatterns, setRawTemplateModePatterns (and the matching get...PatternSpec())

Name patterns that select the mode per template and win over setTemplateMode, which only acts as the fallback.

none

setCacheable(boolean)

Default for whether parsed templates from this resolver go into the cache.

true

setCacheablePatterns, setNonCacheablePatterns (and getCacheablePatternSpec(), getNonCacheablePatternSpec())

Name patterns that override the cacheable flag for specific templates.

none

setCacheTTLMs(Long)

Time to live of a cached template, in milliseconds. Without it an entry only leaves the cache when the maximum size is reached.

null (no TTL)

setUseDecoupledLogic(boolean)

Look for a separate decoupled-logic resource for each template.

false

The defaults in the right column were read back from a freshly created ClassLoaderTemplateResolver: cacheable=true ttl=null mode=HTML checkExistence=false forceSuffix=false forceTemplateMode=false order=null decoupled=false. The same options have the same meaning in every Thymeleaf integration, because Spring’s SpringResourceTemplateResolver extends the same base class.

The four resolvers that read external resources, one after the other
Context ctx = new Context(Locale.ENGLISH, Map.of("name", "Ana"));

// String: the name is the template
StringTemplateResolver sr = new StringTemplateResolver();
sr.setTemplateMode(TemplateMode.TEXT);
TemplateEngine stringEngine = new TemplateEngine();
stringEngine.setTemplateResolver(sr);
stringEngine.process("Dear [(${name})], welcome.", ctx);          // Dear Ana, welcome.

// File: prefix + name is a path
Path dir = Files.createTempDirectory("thy");
Files.writeString(dir.resolve("note.txt"), "File says: [[${name}]]\n");
FileTemplateResolver fr = new FileTemplateResolver();
fr.setPrefix(dir + "/");
fr.setTemplateMode(TemplateMode.TEXT);
fr.setCacheable(false);
TemplateEngine fileEngine = new TemplateEngine();
fileEngine.setTemplateResolver(fr);
fileEngine.process("note.txt", ctx);                              // File says: Ana

// URL: the name is a URL (file:, http:, https:)
UrlTemplateResolver ur = new UrlTemplateResolver();
ur.setTemplateMode(TemplateMode.TEXT);
ur.setCacheable(false);
TemplateEngine urlEngine = new TemplateEngine();
urlEngine.setTemplateResolver(ur);
urlEngine.process(dir.resolve("note.txt").toUri().toString(), ctx); // File says: Ana

// Default: whatever the name, the same text
DefaultTemplateResolver dr = new DefaultTemplateResolver();
dr.setTemplate("<p th:text=\"${name}\">x</p>");
TemplateEngine defEngine = new TemplateEngine();
defEngine.setTemplateResolver(dr);
defEngine.process("whatever-name", ctx);                          // <p>Ana</p>

Two cautions follow from the resolver semantics. A UrlTemplateResolver and a FileTemplateResolver turn the template name into a location, so a template name must never be built from request input: the name ../../etc/passwd or https://attacker.example/x would be a read of a file or a fetch of a URL chosen by the visitor. And a StringTemplateResolver evaluates the text it is given as a template, so it must never receive user-supplied text; user data goes into the context and is output with th:text. The Template Security & XSS page discusses both in depth.

Chaining resolvers: order, resolvable patterns, existence check

An engine can have several resolvers. For each template name it asks them in ascending order and uses the first one that says it can resolve the name; resolvers without an order are asked last, and the order in which resolvers were added does not matter. Two options decide whether a resolver "can resolve" a name:

  • setResolvablePatterns(Set<String>) (or getResolvablePatternSpec().addPattern(...)) lists the name patterns this resolver is willing to handle. A name that matches none is skipped without touching the resource. Patterns are optional but recommended, because they let the engine discard resolvers cheaply.

  • setCheckExistence(true) makes the resolver check that the resource really exists, and pass on to the next resolver if it does not. Without it, a resolver that cannot tell in advance claims every name it is asked about, breaks the chain, and then fails when it cannot read the resource.

Each existence check costs a second access to the resource (once to check, once to read), which hurts for remote URLs, although the template cache softens it because a cached template is only resolved the first time. The same flag also makes the "does this fragment exist" idiom ~{common :: salutation} ?: _ work in templates.

flowchart LR N[Template name] --> R1{Resolver order 1
pattern matches?} R1 -->|no| R2{Resolver order 2
pattern matches?} R1 -->|yes| E1{checkExistence
resource exists?} E1 -->|yes| U1[Use resolver 1] E1 -->|no| R2 R2 -->|yes| E2{checkExistence
resource exists?} R2 -->|no| X[TemplateInputException] E2 -->|yes| U2[Use resolver 2] E2 -->|no| X
A folder of overrides in front of the classpath defaults
// Highest priority: a directory of overrides
FileTemplateResolver overrides = new FileTemplateResolver();
overrides.setOrder(1);
overrides.setName("overrides");
overrides.setPrefix(dir + "/");
overrides.setSuffix(".html");
overrides.setCheckExistence(true);
overrides.setCacheable(false);

// Fallback: the classpath, restricted to a few patterns
ClassLoaderTemplateResolver classpath = new ClassLoaderTemplateResolver();
classpath.setOrder(2);
classpath.setName("classpath");
classpath.setPrefix("templates/");
classpath.setSuffix(".html");
classpath.setResolvablePatterns(Set.of("shared", "layout/*", "hello"));
classpath.setCheckExistence(true);

TemplateEngine engine = new TemplateEngine();
engine.addTemplateResolver(classpath);   // registration order does not matter
engine.addTemplateResolver(overrides);   // the "order" property decides

engine.process("shared", ctx);       // the file wins: <p>file override</p>
engine.process("layout/menu", ctx);  // only the classpath has it: <nav>classpath menu</nav>
engine.process("missing", ctx);      // TemplateInputException

Both a shared.html in the overrides folder and one on the classpath exist, and the first call prints the file’s version; remove the file and the same call prints the classpath’s <p>classpath shared</p>. engine.getTemplateResolvers() returns the resolvers; the names and orders printed from them were [overrides/1, classpath/2].

Contexts, WebContext and the 3.1 web abstraction

A context carries what one rendering needs: the variables map and the locale used for messages. The interface is org.thymeleaf.context.IContext (getLocale(), containsVariable(String), getVariableNames(), getVariable(String)), and the core offers two final implementations:

  • Context, with the constructors Context(), Context(Locale) and Context(Locale, Map<String,Object>), plus setVariable, setVariables and setLocale. This is the one for the console, batch jobs and email. Without a locale, the default locale of the system is used, which you should never let happen in a real application.

  • WebContext, an implementation of IWebContext, with the constructors WebContext(IWebExchange), WebContext(IWebExchange, Locale) and WebContext(IWebExchange, Locale, Map<String,Object>). Beyond the variables, it exposes the request parameters, the session and the application attributes to the template: ${param.q} is a request parameter, ${session.visits} a session attribute, ${application.shop} an application attribute (a servlet context attribute), and a plain ${x} is a context variable or an attribute of the exchange (a request attribute in servlet terms).

A WebApplicationTemplateResolver requires an IWebContext, so the web resolver and WebContext always go together.

IWebApplication and IWebExchange in 3.1

Until Thymeleaf 3.0 a WebContext was built from HttpServletRequest, HttpServletResponse and ServletContext, which tied the core to the javax.servlet API. Thymeleaf 3.1 removed that dependency from the core and replaced the servlet types by its own small abstractions in org.thymeleaf.web:

Type Models

IWebApplication

The application: attributes (getAttributeValue, setAttributeValue, removeAttribute, getAllAttributeNames) and resources (resourceExists, getResourceAsStream). In servlet terms, the servlet context.

IWebExchange

One request-response interchange. Gives access to getRequest(), getSession(), getApplication(), hasSession(), getPrincipal(), getLocale(), getContentType(), getCharacterEncoding(), the exchange attributes and transformURL(String).

IWebRequest

Method, scheme, server name and port, application path, getPathWithinApplication(), getRequestPath(), getRequestURL(), getQueryString(), and headers, parameters and cookies (getParameterValue, getParameterValues, getHeaderValues, getCookieValue, …​).

IWebSession

The session: exists() and the attribute methods.

The servlet bridge lives in org.thymeleaf.web.servlet: JakartaServletWebApplication for jakarta.servlet (Servlet 5 and newer) and JavaxServletWebApplication for the old javax.servlet. Both implement IServletWebApplication and are created with buildApplication(ServletContext) and turn a request and response into an exchange with buildExchange(request, response). The 3.1 changes that follow from this are that the #request, #response, #session and #servletContext expression objects are gone from the Standard Dialect (use ${param.x}, ${session.x} and ${application.x}, or add the data to the context), and that ${execInfo} no longer exists; see the archived Thymeleaf 3.1 what’s new article.

Two behaviours of the servlet bridge surprised the author while testing and are worth knowing before the filter example. First, IWebExchange.getLocale() is read from the response (HttpServletResponse.getLocale()), which is the default locale of the JVM unless something set it. A filter that wants the visitor’s language must copy it with response.setLocale(request.getLocale()) before building the context, or pass request.getLocale() to the WebContext explicitly; without that, a request with Accept-Language: es was rendered with the JVM’s locale. Second, IWebExchange.transformURL(String) is the hook that @{...} link expressions use; in a servlet it calls response.encodeURL, and a first request without a session cookie therefore produced /css/gtvg.css;jsessionid=... as the stylesheet URL.

The Good Thymes Virtual Grocery example

The official tutorial builds its examples on a small web application, the Good Thymes Virtual Grocery (GTVG), which exists in a javax.* and a jakarta.* version. Its web layer is one servlet filter that maps each URL to a small controller object, and each controller creates a context and processes a template. The shape is worth studying, because it is exactly the division of labour that Spring MVC later automates. The listings below are a reduced version of it that was run on embedded Jetty.

The controller contract and two controllers
public interface IGtvgController {
    void process(IWebExchange webExchange, ITemplateEngine templateEngine, Writer writer) throws Exception;
}

record Product(String name, double price) { }

private static final Map<String, IGtvgController> CONTROLLERS = Map.of(
        "/", (exchange, engine, writer) -> {
            exchange.getSession().setAttributeValue("visits",
                    exchange.getSession().exists() && exchange.getSession().containsAttribute("visits")
                            ? (Integer) exchange.getSession().getAttributeValue("visits") + 1 : 1);
            WebContext ctx = new WebContext(exchange, exchange.getLocale());
            ctx.setVariable("today", java.time.LocalDate.of(2026, 10, 7));
            engine.process("home", ctx, writer);
        },
        "/products", (exchange, engine, writer) -> {
            WebContext ctx = new WebContext(exchange, exchange.getLocale());
            ctx.setVariable("prods", List.of(new Product("Onions", 2.41), new Product("Garlic", 1.1)));
            engine.process("products", ctx, writer);
        });
The filter: application, engine and exchange
public class GtvgFilter implements Filter {

    private IWebApplication application;
    private ITemplateEngine templateEngine;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        this.application = JakartaServletWebApplication.buildApplication(filterConfig.getServletContext());
        this.application.setAttributeValue("shop", "GTVG");
        this.templateEngine = buildTemplateEngine(this.application);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        if (!process((HttpServletRequest) req, (HttpServletResponse) res)) {
            chain.doFilter(req, res);                       // not ours: let other filters and servlets run
        }
    }

    private boolean process(HttpServletRequest request, HttpServletResponse response) throws ServletException {
        try {
            JakartaServletWebApplication app = (JakartaServletWebApplication) this.application;
            IWebExchange webExchange = app.buildExchange(request, response);
            IWebRequest webRequest = webExchange.getRequest();

            IGtvgController controller = CONTROLLERS.get(webRequest.getPathWithinApplication());
            if (controller == null) {
                return false;
            }
            response.setLocale(request.getLocale());        // IWebExchange.getLocale() reads the response
            response.setContentType("text/html;charset=UTF-8");
            response.setHeader("Cache-Control", "no-cache");
            controller.process(webExchange, this.templateEngine, response.getWriter());
            return true;
        } catch (Exception e) {
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            } catch (IOException ignored) {
                // nothing else to do
            }
            throw new ServletException(e);
        }
    }

    private static ITemplateEngine buildTemplateEngine(IWebApplication application) {
        WebApplicationTemplateResolver templateResolver = new WebApplicationTemplateResolver(application);
        templateResolver.setTemplateMode(TemplateMode.HTML);
        templateResolver.setPrefix("/WEB-INF/templates/");
        templateResolver.setSuffix(".html");
        templateResolver.setCacheTTLMs(3_600_000L);
        templateResolver.setCacheable(true);

        TemplateEngine engine = new TemplateEngine();
        engine.setTemplateResolver(templateResolver);
        return engine;
    }
}

The resolver is the one from the tutorial: the template mode HTML, the prefix /WEB-INF/templates/, the suffix .html, and a one-hour TTL, after which a cached template is parsed again. The filter can be registered in web.xml or, as in the scratch project, programmatically in an embedded Jetty (new ServletContextHandler("/", ServletContextHandler.SESSIONS) with addFilter(new FilterHolder(new GtvgFilter()), "/*", ...); the sessions option matters, because without a session manager the first controller above fails with "No SessionManager"). The static files under /css or /images are not mapped by the controller map, so the filter lets them through to the container’s default servlet.

The home page template, with its externalised text, request parameter, session and application attributes:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <title>Good Thymes Virtual Grocery</title>
  <link rel="stylesheet" href="../../css/gtvg.css" th:href="@{/css/gtvg.css}" />
</head>
<body>
  <p th:text="#{home.welcome}">Welcome to our grocery store!</p>
  <p>Today is <span th:text="${#temporals.format(today, 'dd MMM yyyy')}">01 Jan 2026</span>,
     lang=<span th:text="${#locale}">en</span>, q=<span th:text="${param.q}">-</span>,
     visits=<span th:text="${session.visits}">0</span>, app=<span th:text="${application.shop}">shop</span></p>
</body>
</html>

The messages live next to the template: /WEB-INF/templates/home.properties for the default, home_es.properties for Spanish. A request curl -H 'Accept-Language: es' 'localhost:8089/?q=a%3Cb' returned (the stylesheet URL carries the session id because that first request had no cookie):

<!DOCTYPE html>
<html>
<head>
  <title>Good Thymes Virtual Grocery</title>
  <link rel="stylesheet" href="/css/gtvg.css;jsessionid=node01xeodmaiz7uiy1qgv6zdkkwqhd1.node0" />
</head>
<body>
  <p>¡Bienvenido a nuestra tienda de comestibles!</p>
  <p>Today is <span>07 oct 2026</span>,
     lang=<span>es</span>, q=<span>a&lt;b</span>,
     visits=<span>1</span>, app=<span>GTVG</span></p>
</body>
</html>

Note that the request parameter a<b came out as a<b: th:text escapes. The same request with a different Accept-Language showed the default English text and, with the session cookie sent back, visits=2; /products returned the <li> list and an unmapped URL returned the container’s 404.

Message resolvers

Messages (#{key} expressions) are looked up through org.thymeleaf.messageresolver.IMessageResolver, which has four methods: getName(), getOrder(), resolveMessage(ITemplateContext, Class<?> origin, String key, Object[] messageParameters) and createAbsentMessageRepresentation(...). If you set nothing, the engine uses a StandardMessageResolver.

The standard resolver reads .properties files that sit beside the template and have its name: for /WEB-INF/templates/home.html and the locale gl_ES it tries, in order, home_gl_ES.properties, home_gl.properties and home.properties. Fragments inserted from other templates also contribute their own files, in insertion order. After the template files it tries properties named after the "origin" class of the caller (for messages requested by a processor written in Java), and finally the default messages added with addDefaultMessage(key, value) or setDefaultMessages(Properties), which do not depend on the locale. If nothing is found, an expression #{no.such.key} renders the absent-message representation, which marks the key and the locale so that a missing translation is visible:

StandardMessageResolver messages = new StandardMessageResolver();
messages.addDefaultMessage("app.name", "Bookshelf");   // locale independent

TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);                  // a TEXT-mode StringTemplateResolver
engine.setMessageResolver(messages);

Context ctx = new Context(Locale.forLanguageTag("gl-ES"));
engine.process("[(#{app.name})] / [(#{no.such.key})]", ctx);

which printed Bookshelf / ??no.such.key_gl_ES??.

A custom resolver is the way to take messages from a database, a service or a map. setMessageResolver sets exactly one; addMessageResolver adds another to the chain, and as with template resolvers they are asked in order, so that when the first returns null the second is tried. If every resolver returns null, a #{...} expression renders the absent-message representation, because such expressions always ask for it; in the run below it came from the first resolver in the chain.

A map-backed resolver in front of the standard one, and a custom conversion service
static class MapMessageResolver extends AbstractMessageResolver {
    private final Map<String, Map<String, String>> byLanguage;

    MapMessageResolver(Map<String, Map<String, String>> byLanguage) {
        this.byLanguage = byLanguage;
    }

    @Override
    public String resolveMessage(ITemplateContext ctx, Class<?> origin, String key, Object[] params) {
        Map<String, String> m = byLanguage.get(ctx.getLocale().getLanguage());
        String pattern = m == null ? null : m.get(key);
        return pattern == null ? null : new MessageFormat(pattern, ctx.getLocale()).format(params);
    }

    @Override
    public String createAbsentMessageRepresentation(ITemplateContext ctx, Class<?> origin, String key, Object[] params) {
        return "[missing:" + key + "]";
    }
}

MapMessageResolver custom = new MapMessageResolver(Map.of(
        "en", Map.of("shop.hello", "Hello {0}"),
        "es", Map.of("shop.hello", "Hola {0}")));
custom.setOrder(1);
StandardMessageResolver standard = new StandardMessageResolver();
standard.setOrder(2);

TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
engine.addMessageResolver(standard);
engine.addMessageResolver(custom);

Conversion services

The double-brace syntax ${{price}} asks for the value to be converted and formatted by a conversion service. That is a feature of the Standard Dialect, not of the engine, so a custom service is set on the StandardDialect that you configure into the engine:

StandardDialect dialect = new StandardDialect();
dialect.setConversionService(new IStandardConversionService() {
    private final IStandardConversionService delegate = new StandardConversionService();

    @Override
    public <T> T convert(IExpressionContext ctx, Object object, Class<T> targetClass) {
        if (object instanceof Double d && targetClass == String.class) {
            return targetClass.cast(String.format(ctx.getLocale(), "%,.2f EUR", d));
        }
        return delegate.convert(ctx, object, targetClass);
    }
});
engine.setDialect(dialect);

The default StandardConversionService simply calls toString() on any object it converts to a String. With the message resolvers above and the template [(#{shop.hello(${n})})] | [(#{nope.key})] | [[${price}]] | [[${{price}}]] in TEXT mode, the two locales printed:

Hello Ana | [missing:nope.key] | 1234.5 | 1,234.50 EUR
Hola Ana | [missing:nope.key] | 1234.5 | 1.234,50 EUR

The third column, the plain ${price}, is untouched by the conversion service; only the double braces go through it. The absent message came from the custom resolver, because it has the lower order. In a Spring application you rarely set one by hand: SpringStandardDialect comes pre-configured with an IStandardConversionService that delegates to Spring’s own conversion service, which is why a Spring application usually never sets one by hand.

Logging

Thymeleaf logs through SLF4J and emits TRACE, DEBUG and INFO messages. Besides the normal per-class loggers (everything under org.thymeleaf), it has special loggers tied to the TemplateEngine class that you can switch on independently:

Logger Output

org.thymeleaf.TemplateEngine.CONFIG

The detailed configuration of the library, printed once when the engine initialises: version, cache manager, template resolvers, message resolvers, link builders, the dialect and every processor with its precedence.

org.thymeleaf.TemplateEngine.TIMER

The time taken to process each template. Useful for benchmarking.

org.thymeleaf.TemplateEngine.cache

Prefix of the cache loggers. By default org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE and org.thymeleaf.TemplateEngine.cache.EXPRESSION_CACHE; their names are configurable on the cache manager.

Switching the special loggers on with slf4j-simple
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.CONFIG=trace
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.TIMER=trace
-Dorg.slf4j.simpleLogger.log.org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE=trace

Running the first example with those flags printed, among other lines (shortened here):

TRACE org.thymeleaf.TemplateEngine.CONFIG - Initializing Thymeleaf Template engine configuration...
[THYMELEAF] * Thymeleaf version: 3.1.5.RELEASE (built 2026-04-21T20:38:36+0000)
[THYMELEAF] * Cache Manager implementation: org.thymeleaf.cache.StandardCacheManager
[THYMELEAF] * Template resolvers:
[THYMELEAF]     * org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
[THYMELEAF] * Message resolvers:
[THYMELEAF]     * org.thymeleaf.messageresolver.StandardMessageResolver
[THYMELEAF] * Dialect: Standard (org.thymeleaf.standard.StandardDialect)
...
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_MISS] Cache miss in cache "TEMPLATE_CACHE" for key "hello".
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_ADD][1] Adding cache entry in cache "TEMPLATE_CACHE" for key "hello". New size is 1.
TRACE org.thymeleaf.TemplateEngine.TIMER - [THYMELEAF][...][hello][es][46257750][46] TEMPLATE "hello" WITH LOCALE es PROCESSED IN 46257750 nanoseconds (approx. 46ms)
TRACE org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE - [THYMELEAF][...][CACHE_HIT] Cache hit in cache "TEMPLATE_CACHE" for key "hello".
TRACE org.thymeleaf.TemplateEngine.TIMER - [THYMELEAF][...][hello][en][395584][0] TEMPLATE "hello" WITH LOCALE en PROCESSED IN 395584 nanoseconds (approx. 0ms)

The timer lines show the effect of the cache directly: the first rendering of hello took 46 ms (a cache miss: read, parse, process) and the second under a millisecond (a cache hit). Use the TEMPLATE_CACHE logger to find templates that never hit the cache, for instance because their resolver is not cacheable. With Log4j or Logback the same logger names go into the usual configuration, for example logger name="org.thymeleaf.TemplateEngine.TIMER" level="TRACE".

The template cache

Thymeleaf parses a template into a sequence of events (open tag, text, close tag, comment, …​) and applies the processors to that sequence. The cache stores the parsed sequence, so that cloning an in-memory event sequence replaces reading and parsing the file on each request. The bet is that a web application has a few dozen small templates that do not change while it runs, so keeping them in memory is cheap and saves most of the input and output. The cache is controlled in two places.

At the resolver (per template origin and per template): setCacheable(boolean) as the default, getCacheablePatternSpec() and getNonCacheablePatternSpec() for specific names, and setCacheTTLMs(Long) for the lifetime of an entry. A TTL does not guarantee the entry lives that long: the cache has a maximum size and expels the oldest entry when it is full.

At the cache manager: the default is org.thymeleaf.cache.StandardCacheManager, with one cache for parsed templates and one for parsed expressions. Its setters (setTemplateCacheMaxSize, setTemplateCacheInitialSize, setTemplateCacheUseSoftReferences, setTemplateCacheName, setTemplateCacheLoggerName, setTemplateCacheValidityChecker, setTemplateCacheEnableCounters, and the same six for the Expression cache) tune it. The defaults are exposed as constants: the template cache holds 200 entries and the expression cache 500 (StandardCacheManager.DEFAULT_TEMPLATE_CACHE_MAX_SIZE and DEFAULT_EXPRESSION_CACHE_MAX_SIZE, printed as 200 and 500 in the run). To go further, implement ICacheManager and give it to the engine with setCacheManager.

You can also empty the cache from code: engine.clearTemplateCache() removes everything, and engine.clearTemplateCacheFor("name") removes one template (the name is the one you pass to process).

Caching, non-cacheable patterns, manual invalidation
FileTemplateResolver resolver = new FileTemplateResolver();
resolver.setPrefix(dir + "/");
resolver.setTemplateMode(TemplateMode.TEXT);
resolver.setCacheable(true);
resolver.setCacheTTLMs(3_600_000L);
resolver.getNonCacheablePatternSpec().addPattern("volatile*");

StandardCacheManager cacheManager = new StandardCacheManager();
cacheManager.setTemplateCacheMaxSize(100);
cacheManager.setExpressionCacheMaxSize(200);

TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
engine.setCacheManager(cacheManager);

engine.process("banner.txt", ctx);                   // file holds v1 -> v1
// now the files are edited: banner.txt becomes v2, volatile.txt a -> b
engine.process("banner.txt", ctx);                   // still v1 (cached)
engine.process("volatile.txt", ctx);                 // b (never cached)
engine.clearTemplateCacheFor("banner.txt");
engine.process("banner.txt", ctx);                   // v2
// edit again to v3
engine.clearTemplateCache();
engine.process("banner.txt", ctx);                   // v3

The program printed v1, then after edit : v1 / b, then after clearCacheFor : v2, then after clearTemplateCache: v3.

The practical rule is: leave the cache on in production, and turn it off for development with setCacheable(false) on the resolver (or a non-cacheable pattern for the templates being edited), because a cached template never notices that its file changed. A cacheable template with no TTL stays valid until the cache is full, so a deployment that replaces template files under a running engine needs a TTL or a call to clearTemplateCache(). The Performance & Production page returns to sizing the cache and to the decoupled-logic option setUseDecoupledLogic(true), which this page lists but does not otherwise cover.

Pitfalls

Symptom Cause and fix

IllegalStateException: Template engine has already been initialized

A setter was called after the first process. Build and configure the engine once at start-up and only then share it.

TemplateInputException: Error resolving template [x]

No resolver could resolve the name. Check prefix, suffix and resolvable patterns, and that a resolver in front of the right one does not claim every name (set checkExistence).

A template or fragment that exists is not found when several resolvers are configured

A resolver without checkExistence and without patterns accepts every name and ends the chain. Give each resolver patterns and an order.

Edits to a template are not visible

The parsed template is cached. Use setCacheable(false) in development, a TTL, or clearTemplateCache().

The page ignores the visitor’s language

IWebExchange.getLocale() comes from the response. Set response.setLocale(request.getLocale()) or pass a locale to the WebContext.

WebApplicationTemplateResolver fails with a plain Context

The web resolver needs an IWebContext. Use WebContext, or switch to a ClassLoaderTemplateResolver for non-web rendering.

??key_es?? appears in the output

The message is missing for that locale and in the default .properties file; add it, or register default messages.

A migrated template uses #request, #session or #servletContext

Those expression objects were removed in 3.1. Use ${param.x}, ${session.x}, ${application.x} or context variables.

Template name, URL or text taken from user input

Read a file or URL chosen by the visitor, or evaluate attacker-supplied template text. Map user input to a fixed set of template names.