Extending Thymeleaf
|
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 (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on
2026-10-08, and every example was compiled and run on that stack (Maven wrapper, a standalone TemplateEngine for the unit tests and
MockMvc against the Bookshelf scratch application for the rendered pages). The extension API belongs to thymeleaf core, so everything
about dialects, processors, expression objects and execution attributes applies to the standalone engine, to Spring MVC and to
Spring WebFlux alike. Only three details are Spring-specific, and each is marked where it appears: reaching the application context
with SpringContextUtils, the Spring message sources behind context.getMessage(...), and registering the dialect as a bean.
Layout Dialect, htmx and the Security extras are not involved.
The running scenario is the Bookshelf of this section. The page builds one dialect, bookshelf:, with a processor of every kind, and
then registers it and tests it. The dialect is deliberately small, and it is not a copy of the two official walk-throughs (the football
league website of the Extending Thymeleaf tutorial and the "hello" dialect of the two Say Hello articles), which are summarised in a
section of their own and listed in the references.
In a hurry: what to write for what
| You want to | Write | Section |
|---|---|---|
A new attribute such as |
A subclass of |
|
A new element such as |
A subclass of |
|
To inspect or rewrite an element together with its whole body |
A subclass of |
|
A new |
An |
|
An object every processor can reach |
An |
|
To see or change every event of every template |
A |
|
The dialect in a Spring Boot application |
A bean of type |
Why extend Thymeleaf
Almost everything a template can do is implemented as a processor that belongs to a dialect. The attributes th:text, th:each and
th:if, the #strings and #dates objects and the link syntax all come from the Standard Dialect (or, with Spring, from the
SpringStandard Dialect that replaces it), and the Layout Dialect and the Spring Security extras are further dialects that the engine treats
exactly like them. That is why the library is described as extensible by design: you use the same mechanism the library uses itself.
Extending is the heavier option, so it pays to check the lighter ones first.
-
A repeated piece of markup belongs in a fragment, as described on the Fragments page, or in a parameterised component, as on Reusable UI Components. No Java is needed.
-
A formatting rule that a Java method can express belongs in a bean called through
${@bean.method(...)}or, when it should read as part of the language, in an expression object (Custom expression objects). -
A vocabulary of your own is the real reason to write a dialect. When a team keeps repeating the same three attributes and a
th:block(a star rating, a stock badge, a promoted ribbon), one attribute with a name from the domain keeps the templates declarative and keeps the rule in one tested class. -
A cross-cutting rule such as removing internal comments, tidying whitespace or adding a footer comment to every page is only possible with comment, boundary, pre- and post-processors.
-
Integration code, such as a library that offers a Thymeleaf-friendly API (the Layout Dialect, htmx integrations, security extras), ships as a dialect so that applications get it by adding a dependency.
Dialects: five interfaces, one name
The root interface, org.thymeleaf.dialect.IDialect, only requires a name used for identification (getName()). What a dialect
contributes is declared by implementing one or more sub-interfaces, and a single class may implement any combination of them.
| Interface | It contributes | Notes |
|---|---|---|
|
Processors, found by element and attribute name. Methods: |
The only kind with a prefix. |
|
Pre-processors, which see every event before any processor does. Methods: |
No prefix: pre-processors do not match on names. |
|
Post-processors, which see every event after all processors ran, just before the output is written. Methods: |
Analogous to the pre-processor dialect. |
|
An |
The factory builds objects lazily, only for the names a template really uses. |
|
Execution attributes: named objects that every processor can read for the whole template execution. Method: |
The Standard Dialect uses it to share its expression parser. |
Three numbers organise the order in which things run, and they are easy to confuse. Each dialect declares a dialect precedence (one per
kind of contribution), and each processor declares its own processor precedence inside that dialect. Lower values run first, and the
dialect precedence is compared before the processor precedence. The Standard Dialect exposes its dialect precedence as the constant
StandardDialect.PROCESSOR_PRECEDENCE (1000), and giving a custom dialect the same value lets its processors interleave with the
standard ones according to their own precedence.
The prefix is only a default. Dialects are asked for their processors with getProcessors(dialectPrefix), and the prefix passed in is the
one chosen when the dialect was added to the engine, which is why a processor must receive it through its constructor and never hard-code
it (see Registering the dialect).
The next sections build the five pieces of the diagram one by one, starting with the processors, and the complete dialect class closes the loop in The dialect class.
Processors
An IProcessor declares the template mode it applies to (getTemplateMode(), one of the six modes of the textual template modes page and the HTML and XML ones) and its precedence. There is one
processor interface for each kind of event Thymeleaf produces while it parses a template: the template start and end, element tags, text,
comments, CDATA sections, DOCTYPE clauses, XML declarations and processing instructions. Because the engine is event-based, a processor is
called while the template is being read and the output is being produced, not on a finished tree.
| Interface | Base class in org.thymeleaf.processor |
Receives |
|---|---|---|
|
|
One open or standalone tag. It cannot see the body, and it is never called for close tags. |
|
|
The whole element as an |
|
|
The template start and the template end events. |
|
|
Each text event. |
|
|
Each comment event. |
CDATA, DOCTYPE, XML declaration, processing instruction |
Matching interfaces in |
Each event of that kind. |
Every process(...)` method of every processor interface follows one pattern: it takes the `+ITemplateContext (variables, locale,
template data, element stack), the event or model being processed, and a structure handler, and it returns void. A processor never
returns a result. It asks the structure handler to do things (set an attribute, replace the element, change the body, define a local
variable) and the engine does them after the call. The events themselves are immutable, which is why attributes are changed through the
handler and not on the tag.
Attribute tag processors
An attribute processor is an element tag processor that matches an attribute name, and AbstractAttributeTagProcessor does the
plumbing: it finds the attribute, unescapes its value, optionally removes it from the output, and calls
doProcess(context, tag, attributeName, attributeValue, structureHandler). The following processor handles
bookshelf:rating="${book.rating}". It evaluates the attribute value as a Standard Expression, reads a message and writes the result as
the body of the host element.
/** Handles {@code bookshelf:rating="${book.rating}"}: replaces the body with "Rated n of 5". */
public class RatingAttributeTagProcessor extends AbstractAttributeTagProcessor {
private static final String ATTR_NAME = "rating";
private static final int PRECEDENCE = 1300;
public RatingAttributeTagProcessor(final String dialectPrefix) {
super(
TemplateMode.HTML, // only HTML templates
dialectPrefix, // prefix used to build the attribute name
null, // no element name: match any element
false, // no prefix on the element name
ATTR_NAME, // attribute name, so bookshelf:rating
true, // apply the dialect prefix to the attribute name
PRECEDENCE, // precedence inside the dialect
true); // remove the attribute from the output
}
@Override
protected void doProcess(
final ITemplateContext context, final IProcessableElementTag tag,
final AttributeName attributeName, final String attributeValue,
final IElementTagStructureHandler structureHandler) {
// Evaluate the attribute value as a Thymeleaf Standard Expression
final var parser = StandardExpressions.getExpressionParser(context.getConfiguration());
final IStandardExpression expression = parser.parseExpression(context, attributeValue);
final Object value = expression.execute(context);
final int rating = value instanceof Number n ? Math.max(0, Math.min(5, n.intValue())) : 0;
final String message = context.getMessage(
RatingAttributeTagProcessor.class, "rating.label", new Object[] {rating}, true);
// Text set as the body is not escaped for us, and false means "do not process it again"
structureHandler.setBody(HtmlEscape.escapeHtml5(message), false);
}
}
The eight constructor arguments of AbstractAttributeTagProcessor are worth reading once, because they appear in every attribute
processor: the template mode, the dialect prefix, an optional element name (null matches any element) with a flag saying whether the
prefix is applied to it, the attribute name with the same kind of flag, the precedence, and a last flag that removes the attribute from the
output. With the prefix applied, the attribute that matches is bookshelf:rating, and with the removal flag on, it never reaches the browser.
The message comes from context.getMessage(origin, key, parameters, useAbsentMessageRepresentation). In a Spring application the call
goes to the Spring message sources first, and falls back to .properties files named after the origin class and found on the classpath next to
it, which lets a processor ship its own texts inside the jar that contains it. The example uses the second route, with these two files
in com/example/bookshelf/extension/ on the classpath:
rating.label=Rated {0} of 5
rating.label=Valorado con {0} de 5
A second attribute processor shows the other typical job, changing an attribute of the host element. Because the tag is immutable, the processor reads the current class from the tag and asks the structure handler to set the new value.
/** Handles {@code bookshelf:stock="${book.copies}"}: appends a CSS class to the host element. */
public class StockAttributeTagProcessor extends AbstractAttributeTagProcessor {
public StockAttributeTagProcessor(final String dialectPrefix) {
super(TemplateMode.HTML, dialectPrefix, null, false, "stock", true, 1200, true);
}
@Override
protected void doProcess(
final ITemplateContext context, final IProcessableElementTag tag,
final AttributeName attributeName, final String attributeValue,
final IElementTagStructureHandler structureHandler) {
final var parser = StandardExpressions.getExpressionParser(context.getConfiguration());
final Object value = parser.parseExpression(context, attributeValue).execute(context);
final int copies = value instanceof Number n ? n.intValue() : 0;
final String cssClass = copies <= 0 ? "out-of-stock" : copies < 3 ? "low-stock" : "in-stock";
// The tag is immutable: ask the engine to set the attribute, keeping any existing classes
final String current = tag.getAttributeValue("class");
structureHandler.setAttribute("class", current == null ? cssClass : current + " " + cssClass);
}
}
Used on <li class="book" bookshelf:stock="${n}"> with n equal to 1, the engine produced (standalone engine, run during
the tests):
<li class="book low-stock">x</li>
Source: Say Hello! Extending Thymeleaf in 5 minutes, Extending Thymeleaf — Changing the CSS class by team position, Javadoc: AbstractAttributeTagProcessor
The structure handler of a tag processor (IElementTagStructureHandler) is the vocabulary of what a tag processor can ask for.
| Method | Effect |
|---|---|
|
Defines or removes a local variable for the rest of the element’s processing. |
|
Adds or changes an attribute, replaces one by another with a new name, or removes it. |
|
Changes the selection target (the object |
|
Replaces the body with text or with a model. The boolean says whether the new body is processable. |
|
Adds a model before the element (never processable) or right after it. |
|
Replaces the whole element with text or with a model. |
|
Removes the element, its two tags, its body, or everything but the first child. |
|
Repeats the element for each item of a collection, as |
Element tag processors
An element processor matches a tag name instead of an attribute and usually replaces the whole element with generated markup.
<bookshelf:new-arrivals limit="2"/> is replaced by a list of titles read from the BookRepository bean. The processor reaches
Spring through SpringContextUtils.getApplicationContext(context) (from thymeleaf-spring6), builds the markup with the model
factory and asks the engine to put it in the element’s place.
/** Handles {@code <bookshelf:new-arrivals limit="2"/>}: replaces the element with a list of titles. */
public class NewArrivalsElementTagProcessor extends AbstractElementTagProcessor {
private static final String TAG_NAME = "new-arrivals";
private static final int DEFAULT_LIMIT = 3;
private static final int MAX_LIMIT = 10;
public NewArrivalsElementTagProcessor(final String dialectPrefix) {
super(
TemplateMode.HTML, // only HTML templates
dialectPrefix, // prefix used to build the element name
TAG_NAME, // element name, so bookshelf:new-arrivals
true, // apply the dialect prefix to the element name
null, // no attribute name: match by element only
false, // no prefix on the attribute name
1000); // precedence inside the dialect
}
@Override
protected void doProcess(
final ITemplateContext context, final IProcessableElementTag tag,
final IElementTagStructureHandler structureHandler) {
// A Spring bean, reached through the application context the engine runs in
final ApplicationContext applicationContext = SpringContextUtils.getApplicationContext(context);
final List<Book> books = applicationContext.getBean(BookRepository.class).findAll();
// A plain literal attribute, parsed defensively; it is not evaluated as an expression
int limit = DEFAULT_LIMIT;
final String requested = tag.getAttributeValue("limit");
if (requested != null && requested.matches("\\d{1,2}")) {
limit = Math.min(Integer.parseInt(requested), MAX_LIMIT);
}
// A model is a flat sequence of events, so open and close tags are created one by one
final IModelFactory modelFactory = context.getModelFactory();
final IModel model = modelFactory.createModel();
model.add(modelFactory.createOpenElementTag("ul", "class", "new-arrivals"));
for (final Book book : books.stream().limit(limit).toList()) {
model.add(modelFactory.createOpenElementTag("li"));
model.add(modelFactory.createText(HtmlEscape.escapeHtml5(book.getTitle())));
model.add(modelFactory.createCloseElementTag("li"));
}
model.add(modelFactory.createCloseElementTag("ul"));
// false: the generated events must not be processed again by the engine
structureHandler.replaceWith(model, false);
}
}
Three things in this processor are deliberate. First, the limit attribute is read as a plain string and accepted only when it is one or two
digits, so it is never evaluated as an expression (Security: what a processor may evaluate explains why). Second, the text of every title is escaped before it becomes a
text event (HTML escaping is your job). Third, replaceWith(model, false) marks the generated events as not processable, so the engine does not look for
th:* or bookshelf:* attributes in them.
IModelFactory and models
A model (IModel) is a sequence of events, not a tree of nodes. That is why the list above opens the ul, adds a li open tag, a
text and a li close tag, and so on, one event at a time, in the right order. context.getModelFactory() returns the IModelFactory
that creates the events.
| Method | Creates |
|---|---|
|
An empty model, or a model that holds one event. |
|
Tags, with overloads that take one attribute, a map of attributes with their quoting, or flags such as synthetic and minimized. |
|
A text or a comment event. The text is written as it is: it is not escaped for you. |
|
A model obtained by parsing a markup string with the parser of the current template mode. |
Besides building models, the processor can walk one. IModel has size(), get(int), add, insert, remove and an
accept(IModelVisitor) that applies the visitor pattern, which is how a model processor looks for nodes inside a body.
Element model processors
A tag processor sees one tag. A model processor sees the whole element, which is what is needed to wrap, filter, reorder or append. The
processor below handles bookshelf:promoted, a flag attribute without a value, and appends a ribbon right after the element it is used on.
It refuses to work outside an element with the class catalog, which is checked by walking the element stack of the context: the last
entry of the stack is the element being processed, and the entries before it are its ancestors.
/** Handles {@code bookshelf:promoted}: adds a ribbon right after the element, only inside .catalog. */
public class PromotedModelProcessor extends AbstractAttributeModelProcessor {
private static final String ATTR_NAME = "promoted";
public PromotedModelProcessor(final String dialectPrefix) {
super(TemplateMode.HTML, dialectPrefix, null, false, ATTR_NAME, true, 1100, true);
}
@Override
protected void doProcess(
final ITemplateContext context, final IModel model,
final AttributeName attributeName, final String attributeValue,
final IElementModelStructureHandler structureHandler) {
if (!insideCatalog(context)) {
throw new TemplateProcessingException(
"bookshelf:" + ATTR_NAME + " can only be used inside an element with class \"catalog\"");
}
// The model holds the whole element: open tag, body and close tag. Appending puts the
// new events after the element's own close tag.
final IModelFactory modelFactory = context.getModelFactory();
model.add(modelFactory.createOpenElementTag("span", "class", "ribbon"));
model.add(modelFactory.createText("Promoted"));
model.add(modelFactory.createCloseElementTag("span"));
}
private static boolean insideCatalog(final ITemplateContext context) {
// The last entry of the element stack is the element being processed
final List<IProcessableElementTag> stack = context.getElementStack();
for (int i = stack.size() - 2; i >= 0; i--) {
final IProcessableElementTag ancestor = stack.get(i);
if (ancestor instanceof IOpenElementTag) {
final String css = ancestor.getAttributeValue("class");
if (css != null && (" " + css + " ").contains(" catalog ")) {
return true;
}
}
}
return false;
}
}
The model that arrives in doProcess already contains the element, and the processor edits that same model instead of creating a new one.
Used inside and outside the required container, the standalone engine gave:
<div class="catalog"><p>x</p><span class="ribbon">Promoted</span></div>
org.thymeleaf.exceptions.TemplateProcessingException: bookshelf:promoted can only be used inside an element with class "catalog" (template: "<p bookshelf:promoted>x</p>" - line 1, col 4)
The exception is wrapped by parser exceptions on its way out of the engine (the outer type is TemplateInputException), so a test
looks at the root cause, as shown in Testing a dialect. The structure handler of a model processor (IElementModelStructureHandler) is a subset
of the one for tags: local variables, the selection target, the inliner and the template data.
Template boundaries, comments and text
Three simpler processor kinds finish the set. A comment processor receives each comment event and can remove it. This one removes the
comments that start with internal: and leaves the others alone. Comments written for the team never reach the browser, which is the same
idea as the parser-level comment blocks of the natural templates page,
applied to ordinary comments.
/** Removes HTML comments that start with "internal:" from the output. */
public class InternalCommentProcessor extends AbstractCommentProcessor {
public InternalCommentProcessor() {
super(TemplateMode.HTML, 900);
}
@Override
protected void doProcess(
final ITemplateContext context, final IComment comment,
final ICommentStructureHandler structureHandler) {
if (comment.getContent().stripLeading().startsWith("internal:")) {
structureHandler.removeComment();
}
}
}
A template boundaries processor receives the template start and the template end, and its structure handler (ITemplateBoundariesStructureHandler)
can insert text or a model at the very beginning or the very end of the result, plus the usual local variable, selection target and inliner
methods. The next processor writes a comment at the end of each template that the engine processes.
/** Writes a comment at the very end of every HTML template that goes through the engine. */
public class RenderedByBoundariesProcessor extends AbstractTemplateBoundariesProcessor {
public RenderedByBoundariesProcessor() {
super(TemplateMode.HTML, 800);
}
@Override
public void doProcessTemplateStart(
final ITemplateContext context, final ITemplateStart templateStart,
final ITemplateBoundariesStructureHandler structureHandler) {
// nothing to do at the start
}
@Override
public void doProcessTemplateEnd(
final ITemplateContext context, final ITemplateEnd templateEnd,
final ITemplateBoundariesStructureHandler structureHandler) {
structureHandler.insert("<!-- rendered with the bookshelf dialect -->", false);
}
}
Text processors are written the same way, with AbstractTextProcessor and doProcess(context, text, structureHandler). Its structure
handler (ITextStructureHandler) offers setText, replaceWith and removeText. A text processor runs for every text event of the
template, so it has to be cheap and it should return as soon as it sees that a text is of no interest.
Evaluating expressions inside a processor
The attribute value a processor receives is only text. To make it behave as in the standard attributes (so that ${...}, #{...},
@{...}, *{...}, conditionals and the rest of the language work) the processor uses the Standard Expression parser:
final var parser = StandardExpressions.getExpressionParser(context.getConfiguration());
final IStandardExpression expression = parser.parseExpression(context, attributeValue);
final Object value = expression.execute(context);
StandardExpressions.getExpressionParser(IEngineConfiguration) returns the parser that the Standard Dialect put in the execution
attributes (this is why an IExecutionAttributeDialect exists, see Execution attributes), parseExpression turns the text into an
IStandardExpression, and execute(context) evaluates it against the variables of the current execution. The result has no fixed type:
a processor must check it, as RatingAttributeTagProcessor does with value instanceof Number, and decide what to do with a null. The
parsed expressions are cached by the engine, so parsing on every call is not a performance problem.
The attribute can also be a literal. In Standard Expression syntax a quoted text is already an expression, so bookshelf:rating="4",
bookshelf:rating="${book.rating}" and bookshelf:rating="${book.rating} + 1" all work with the same code, and that is what makes the
attribute feel native. The expression syntax is covered by Standard Expression Syntax.
Messages go through the context as well. context.getMessage(origin, key, parameters, useAbsentMessageRepresentation) uses the message
resolution configured in the engine. With the last argument true, a missing key produces an "absent message" label instead of an
exception.
HTML escaping is your job
Text that a processor writes is not escaped by Thymeleaf. setBody(text, ...) and createText(...) write the characters they are given, and
that is what a processor needs when it generates markup, but it also means that every value that came from data must be escaped by the
processor before it is written. The org.unbescape.html.HtmlEscape class, a dependency of thymeleaf itself, does this, and
HtmlEscape.escapeHtml5(String) is the method the official examples use.
The following processor, written for a test, evaluates its attribute as an expression and writes the result as the body, once with
escape off and once on. The variable title holds Tom & <b>Jerry</b>.
class WriteProcessor extends AbstractAttributeTagProcessor {
private final boolean escape;
WriteProcessor(String prefix, String name, boolean escape) {
super(TemplateMode.HTML, prefix, null, false, name, true, 1000, true);
this.escape = escape;
}
@Override
protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
AttributeName attributeName, String attributeValue, IElementTagStructureHandler handler) {
final Object result = StandardExpressions.getExpressionParser(context.getConfiguration())
.parseExpression(context, attributeValue).execute(context);
final String text = String.valueOf(result);
handler.setBody(escape ? HtmlEscape.escapeHtml5(text) : text, false);
}
}
With <p p:raw="${title}">x</p> and <p p:safe="${title}">x</p> the engine wrote:
<p>Tom & <b>Jerry</b></p>
<p>Tom & <b>Jerry</b></p>
The first line is markup injected from data. The second is what the processors of this page produce. The same applies to attribute values that a processor sets from data, and to the context: an attribute value must be escaped for attributes, a text for HTML text. Template Security & XSS lists every context in which Thymeleaf escapes for you and every one in which it does not.
The values that need no escaping are the ones the processor chooses from a closed set, like the CSS classes of StockAttributeTagProcessor.
Custom expression objects
An expression object is a name starting with # that is available in every expression, like #strings. To add one, a dialect
implements IExpressionObjectDialect and returns an IExpressionObjectFactory. The factory does not return the objects. It declares
their names and builds an object only when a template uses the name, because building them needs the processing context and most templates
use few of them.
/** The object behind the {@code #bookshelf} expression object. */
public class BookshelfExpressions {
private static final int RECENT_DAYS = 30;
private final Clock clock;
public BookshelfExpressions(final Clock clock) {
this.clock = clock;
}
/** 9780134685991 becomes 978-0-13-468599-1; anything that is not 13 digits is returned as is. */
public String isbn(final String digits) {
if (digits == null || !digits.matches("\\d{13}")) {
return digits;
}
return digits.substring(0, 3) + "-" + digits.charAt(3) + "-" + digits.substring(4, 6) + "-"
+ digits.substring(6, 12) + "-" + digits.charAt(12);
}
/** True when the date is no more than 30 days before today, as told by the configured clock. */
public boolean isRecent(final LocalDate published) {
if (published == null) {
return false;
}
final long days = ChronoUnit.DAYS.between(published, LocalDate.now(this.clock));
return days >= 0 && days <= RECENT_DAYS;
}
}
public class BookshelfExpressionObjectFactory implements IExpressionObjectFactory {
static final String NAME = "bookshelf";
@Override
public Set<String> getAllExpressionObjectNames() {
return Set.of(NAME);
}
@Override
public Object buildObject(final IExpressionContext context, final String expressionObjectName) {
if (!NAME.equals(expressionObjectName)) {
return null;
}
// Execution attributes are shared by every processor and expression object of the engine
final Clock clock = (Clock) context.getConfiguration()
.getExecutionAttributes().get(BookshelfDialect.CLOCK_ATTRIBUTE);
return new BookshelfExpressions(clock);
}
@Override
public boolean isCacheable(final String expressionObjectName) {
// The object has no per-evaluation state, so one instance can serve the whole execution
return true;
}
}
isCacheable(name) tells the engine whether the object it built can be kept for the rest of the template execution. Return true for
a stateless object, as here, and false when the object must be rebuilt on each use. In the template, the object is used like any
other (the page of Expression Utility Objects lists the standard ones):
<code th:text="${#bookshelf.isbn(b.isbn)}">978-0-00-000000-0</code>
<em th:if="${#bookshelf.isRecent(b.published)}">New</em>
Given the ISBN 9780134685991 the first expression printed 978-0-13-468599-1, and a value that is not 13 digits (not-an-isbn)
was printed unchanged. A method that returns a value from a different library than Thymeleaf’s own needs no registration beyond the
factory: Standard Expression syntax calls any public method.
Execution attributes
An execution attribute is a named object stored in the engine configuration, available to every processor and expression object through
context.getConfiguration().getExecutionAttributes(). A dialect contributes them by implementing IExecutionAttributeDialect. The
Standard Dialect uses this to share its expression parser, and the Bookshelf dialect uses it to share its java.time.Clock. The factory
above reads it, and that is what lets a test replace the clock with a fixed one:
final Clock clock = (Clock) context.getConfiguration()
.getExecutionAttributes().get(BookshelfDialect.CLOCK_ATTRIBUTE);
The map belongs to the engine, so it is meant for collaborators that are configured once and are safe for concurrent use, such as a clock, a parser or a repository. State that changes with each request belongs in the context variables.
Pre-processors and post-processors
Pre- and post-processors are different from the processors above. They do not match elements by name. They are handlers of the event
stream: a PreProcessor or PostProcessor is created with a template mode, the class of an ITemplateHandler and a precedence, and
the engine instantiates that class itself and places the instance in the chain. The handler extends AbstractTemplateHandler,
overrides the handleXxx methods it cares about and calls the super method to pass the event on (or a replacement for it). A
pre-processor sees the raw events as the parser produced them, before any processor, and a post-processor sees the result after all of them.
/** Post-processing handler: collapses every whitespace-only text event into a single newline. */
public class TidyWhitespaceHandler extends AbstractTemplateHandler {
@Override
public void handleText(final IText text) {
final String content = text.getText();
if (content.length() > 1 && content.isBlank()) {
super.handleText(getContext().getModelFactory().createText("\n"));
} else {
super.handleText(text);
}
}
}
/** Pre-processing handler: sees every event before the processors do. Here it only logs. */
public class TemplateNameHandler extends AbstractTemplateHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(TemplateNameHandler.class);
@Override
public void handleTemplateStart(final ITemplateStart templateStart) {
LOGGER.debug("Processing template {}", getContext().getTemplateData().getTemplate());
super.handleTemplateStart(templateStart);
}
}
Because the engine creates the handler from its class, the handler cannot receive collaborators through its constructor. The two
examples above have a public constructor without arguments, and anything else a handler needs has to come from the context
(getContext()) or from static state. Both classes run for every event of every
template of the given mode: keep them small.
The dialect class
The dialect extends AbstractProcessorDialect (name, default prefix, dialect precedence) and implements the other four interfaces. This is
the class that the diagram near the top of the page draws.
public class BookshelfDialect extends AbstractProcessorDialect
implements IPreProcessorDialect, IPostProcessorDialect,
IExpressionObjectDialect, IExecutionAttributeDialect {
public static final String DIALECT_NAME = "Bookshelf Dialect";
public static final String DEFAULT_PREFIX = "bookshelf";
static final String CLOCK_ATTRIBUTE = "bookshelf.clock";
private final Clock clock;
public BookshelfDialect() {
this(Clock.systemDefaultZone());
}
public BookshelfDialect(final Clock clock) {
// The same processor precedence as the Standard Dialect lets the executions interleave
super(DIALECT_NAME, DEFAULT_PREFIX, StandardDialect.PROCESSOR_PRECEDENCE);
this.clock = clock;
}
@Override
public Set<IProcessor> getProcessors(final String dialectPrefix) {
return Set.of(
new RatingAttributeTagProcessor(dialectPrefix),
new StockAttributeTagProcessor(dialectPrefix),
new NewArrivalsElementTagProcessor(dialectPrefix),
new PromotedModelProcessor(dialectPrefix),
new InternalCommentProcessor(),
new RenderedByBoundariesProcessor(),
// removes the xmlns:bookshelf declaration we add so that IDEs stop complaining
new StandardXmlNsTagProcessor(TemplateMode.HTML, dialectPrefix));
}
@Override
public int getDialectPreProcessorPrecedence() {
return StandardDialect.PROCESSOR_PRECEDENCE;
}
@Override
public Set<IPreProcessor> getPreProcessors() {
return Set.of(new PreProcessor(TemplateMode.HTML, TemplateNameHandler.class, 1000));
}
@Override
public int getDialectPostProcessorPrecedence() {
return StandardDialect.PROCESSOR_PRECEDENCE;
}
@Override
public Set<IPostProcessor> getPostProcessors() {
return Set.of(new PostProcessor(TemplateMode.HTML, TidyWhitespaceHandler.class, 1000));
}
@Override
public IExpressionObjectFactory getExpressionObjectFactory() {
return new BookshelfExpressionObjectFactory();
}
@Override
public Map<String, Object> getExecutionAttributes() {
return Map.of(CLOCK_ATTRIBUTE, this.clock);
}
}
StandardXmlNsTagProcessor is a processor of the Standard Dialect that removes the xmlns:bookshelf attribute from the output.
Declaring that namespace on the root element is optional. It exists only to keep IDEs from complaining about unknown attributes and elements, and
the processor keeps the declaration out of the HTML that is sent to the browser.
The official examples at a glance
The two official walk-throughs teach the same mechanics with different domains, and each maps onto a piece of this page.
| Official example | What it shows | On this page |
|---|---|---|
Say Hello!, a |
The smallest useful dialect: an |
|
Say Hello Again!, the same dialect extended |
Parsing and executing attribute values as Standard Expressions, and internationalizing the message with |
|
extraThyme, a football league website, in section 3 of the tutorial |
An attribute processor that changes a CSS class, one that writes an internationalized remark, an element processor that builds markup with the model factory and reads a Spring bean, a model processor that adds a banner after an element, and the |
Attribute tag processors, Element tag processors, Element model processors, The dialect class |
The tutorial states that the source code of its examples is available as the extraThyme application, in Spring 5 and Spring 6 variants.
The processors there use the same classes as this page. Only the domain changes, so reading them next to the code above is a good exercise.
Registering the dialect
In Spring Boot
Spring Boot adds every bean of type IDialect to the SpringTemplateEngine it configures, so a dialect needs a bean definition and
nothing else. The bean can be a @Component on the dialect class, or a @Bean method in a configuration class, and the beans are added in
@Order order. This is the same mechanism described in the Extra dialect beans section of
Spring Boot Auto-Configuration & Properties.
@Configuration(proxyBeanMethods = false)
class ThymeleafExtensionConfig {
@Bean
BookshelfDialect bookshelfDialect() {
return new BookshelfDialect();
}
}
Annotating BookshelfDialect with @Component has the same effect. The test of Testing a dialect checks that the dialect is among the dialects of
the auto-configured engine. No spring.thymeleaf.* property is involved.
In plain Spring or standalone
When the engine is created by hand, as in a Spring application without Boot or a standalone program, the dialect is added to it. The method
addDialect(IDialect) adds the dialect to the ones already present, so the standard th:* attributes keep working, and
setDialect, as the Say Hello article also says, replaces them. addDialect(String prefix, IDialect dialect) adds it under a prefix of
your choice, which overrides the default one:
final TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(new StringTemplateResolver());
engine.addDialect("shelf", new BookshelfDialect());
// <li shelf:stock="${n}">x</li> now works, and bookshelf:stock does not
With n set to 0 the output was <li class="out-of-stock">x</li>. This is the reason getProcessors(dialectPrefix) receives the
prefix as an argument. The dialect in a plain Spring application is added in the SpringTemplateEngine bean, as the official articles
show, and the engine configuration is the subject of the standalone engine page.
The dialect in a page
The template combines the standard attributes and all the processors of the dialect. The two comments at the top test the comment processor,
the bookshelf:new-arrivals element uses a Spring bean, and the last paragraph is a promoted one.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:bookshelf="http://www.example.com/bookshelf">
<body>
<!--internal: remove before publishing -->
<!-- visible comment -->
<section class="catalog">
<h1>Catalog</h1>
<bookshelf:new-arrivals limit="2"/>
<ul>
<li th:each="b : ${books}" class="book" bookshelf:stock="${b.copies}">
<span th:text="${b.title}">Title</span>
<span bookshelf:rating="${b.rating}">Rated 5 of 5</span>
<code th:text="${#bookshelf.isbn(b.isbn)}">978-0-00-000000-0</code>
<em th:if="${#bookshelf.isRecent(b.published)}">New</em>
</li>
</ul>
<p bookshelf:promoted>Featured this week</p>
</section>
</body>
</html>
The controller puts three books in the model, one of them with a title that contains markup characters and one with an invalid rating and no publication date:
@Controller
public class ShelfController {
public record Shelved(String title, int rating, int copies, String isbn, LocalDate published) { }
@GetMapping("/extension/shelf")
String shelf(Model model) {
model.addAttribute("books", List.of(
new Shelved("Dune", 5, 4, "9780441013593", LocalDate.of(2026, 9, 20)),
new Shelved("Tom & Jerry <b>", 3, 1, "9780134685991", LocalDate.of(1999, 1, 1)),
new Shelved("Ubik", 9, 0, "not-an-isbn", null)));
return "extension/shelf";
}
}
For an English request, with the repository holding three books and a clock fixed at 2026-10-08, the response was the following. The
internal: comment is gone, the visible one stays, the markup characters of the second title are escaped, the rating 9 is limited to 5, the
whitespace is tidied by the post-processor and the boundary comment closes the document:
<!DOCTYPE html>
<html>
<body>
<!-- visible comment -->
<section class="catalog">
<h1>Catalog</h1>
<ul class="new-arrivals"><li>Dune</li><li>Emma <i></li></ul>
<ul>
<li class="book in-stock">
<span>Dune</span>
<span>Rated 5 of 5</span>
<code>978-0-44-101359-3</code>
<em>New</em>
</li>
<li class="book low-stock">
<span>Tom & Jerry <b></span>
<span>Rated 3 of 5</span>
<code>978-0-13-468599-1</code>
</li>
<li class="book out-of-stock">
<span>Ubik</span>
<span>Rated 5 of 5</span>
<code>not-an-isbn</code>
</li>
</ul>
<p>Featured this week</p><span class="ribbon">Promoted</span>
</section>
</body>
</html>
<!-- rendered with the bookshelf dialect -->
With the request locale es, only the rating lines change, because the message comes from the _es file of the processor:
<span>Valorado con 5 de 5</span>
<span>Valorado con 3 de 5</span>
<span>Valorado con 5 de 5</span>
Testing a dialect
A dialect is Java code with a text-in, text-out contract, so it is tested at three levels. The first needs no Spring at all: a standalone
TemplateEngine with a StringTemplateResolver takes the template as a string, the dialect is added, a Context provides the variables and
locale, and the result is an ordinary string. This is the level where every processor is exercised, with a fixed clock for anything that depends
on time.
class BookshelfDialectTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-08T10:00:00Z"), ZoneOffset.UTC);
private TemplateEngine engine() {
final TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(new StringTemplateResolver());
engine.addDialect(new BookshelfDialect(CLOCK));
return engine;
}
@Test
void ratingIsLimitedToFive() {
final Context ctx = new Context(Locale.ENGLISH);
ctx.setVariable("rating", 9);
final String html = engine().process("<p bookshelf:rating=\"${rating}\">x</p>", ctx);
assertThat(html).contains("<p>Rated 5 of 5</p>");
}
@Test
void expressionObjectUsesTheClock() {
final Context ctx = new Context(Locale.ENGLISH);
ctx.setVariable("d", LocalDate.of(2026, 10, 1));
final String html = engine().process(
"<p th:text=\"${#bookshelf.isbn('9780134685991')} + ' ' + ${#bookshelf.isRecent(d)}\"></p>", ctx);
assertThat(html).contains("978-0-13-468599-1 true");
}
@Test
void promotedOutsideCatalogFails() {
assertThatThrownBy(() -> engine().process("<p bookshelf:promoted>x</p>", new Context()))
.hasRootCauseInstanceOf(TemplateProcessingException.class)
.rootCause().hasMessageContaining("catalog");
}
}
The boundary comment of Template boundaries, comments and text is part of every output of this engine, which is why the assertions use contains and not
isEqualTo. All six tests of the class passed.
The second level is a Spring Boot test that proves the wiring: the dialect is registered in the auto-configured engine, the Spring bean is
reachable from the element processor, and a real controller renders the page. The test replaces the dialect with one that has a fixed clock by
importing a @TestConfiguration that declares a bean of the same type.
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@Import(ShelfPageTest.FixedClockConfig.class)
class ShelfPageTest {
@TestConfiguration(proxyBeanMethods = false)
static class FixedClockConfig {
@Bean
BookshelfDialect bookshelfDialect() {
return new BookshelfDialect(Clock.fixed(Instant.parse("2026-10-08T10:00:00Z"), ZoneOffset.UTC));
}
}
@Autowired MockMvc mvc;
@Autowired SpringTemplateEngine engine;
@Test
void dialectIsRegisteredByBoot() {
assertThat(engine.getDialects()).anyMatch(d -> d instanceof BookshelfDialect);
}
@Test
void rendersTheShelf() throws Exception {
mvc.perform(get("/extension/shelf").locale(Locale.ENGLISH))
.andExpect(status().isOk())
.andExpect(content().string(containsString("<span>Rated 5 of 5</span>")))
.andExpect(content().string(not(containsString("internal:"))));
}
}
@AutoConfigureMockMvc is in org.springframework.boot.webmvc.test.autoconfigure since Spring Boot 4. The third level, browser-driven tests of the
whole page, belongs to End-to-End Testing with Real Browsers. A dedicated Thymeleaf testing library exists, thymeleaf-testing (see Testing),
but it has no 3.1 release: the last release of the 3.x line is 3.0.4.RELEASE and 3.1.0.M2 is a milestone, so the tests above avoid it.
Security: what a processor may evaluate
Writing a processor moves responsibilities from the template engine to you, and two of them decide whether the dialect is safe.
-
Never evaluate untrusted text as an expression. Attribute values of the template belong to the template author, and evaluating them is the purpose of the processor. A text that came from a user, a database row or a request parameter does not. Passing it to
parseExpressionis the server-side template injection problem described in Template Security & XSS. This is whyNewArrivalsElementTagProcessorreadslimitas a number it validates with a regular expression, and not as an expression. -
Choose the execution context when an expression may be risky.
execute(context)runs the expression in the normal mode.execute(context, StandardExpressionExecutionContext.RESTRICTED)runs it in the restricted mode described in Standard Expression Syntax. In a standalone engine with the OGNL dialect, an expression that uses a static class (${@java.lang.Math@abs(-1)}) gave1in the normal mode and, in the restricted one,TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is forbidden in this context. Plain variables and literals worked in both. -
Escape what you write. See HTML escaping is your job.
-
Think before marking output as processable.
setBody(text, true)andreplaceWith(model, true)hand the generated events back to the engine, which processes anyth:*or dialect attributes they contain. With a literal built by the processor that is fine, and with data in it, it executes whatever the data says. Usefalsefor anything that includes data.
Pitfalls
-
Forgetting the dialect prefix argument. A processor that hard-codes
bookshelfstops working the day someone adds the dialect with another prefix. PassdialectPrefixto the constructors and let the base class build the names. -
setDialectinstead ofaddDialect.setDialectreplaces the dialects of the engine, so the standard attributes disappear and everyth:*stays in the output untouched.addDialectkeeps them. In Boot, the bean approach adds. -
Mutating the tag. Tags and events are immutable: calling a getter and changing a copy has no effect. Ask the structure handler.
-
Not escaping. See HTML escaping is your job. It is the most common defect in custom processors.
-
A tag processor that needs the body. A tag processor cannot see it. Use a model processor.
-
Expecting a close-tag callback. Element tag processors run for open and standalone tags only.
-
Unbounded work in text, comment and pre/post handlers. They run for every event, so slow code in them multiplies by the size of every page.
-
Spring-only calls in shared code.
SpringContextUtilsand the Spring message sources exist only in a Spring-integrated engine. A dialect that must also run standalone should receive its collaborators as constructor arguments or execution attributes and not look them up. -
Looking for the wrapped exception. A
TemplateProcessingExceptionthrown from a processor arrives wrapped, so tests look at the root cause. -
Tutorial examples written for older stacks. The official extraThyme sources have Spring 5 and Spring 6 variants. The processor API is the same in 3.1, but the Spring package names are
org.thymeleaf.spring6with Spring Boot 4, and there is nothymeleaf-spring7artifact.