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

Table 1. Choosing the extension point
You want to Write Section

A new attribute such as bookshelf:rating="..." that changes its own element

A subclass of AbstractAttributeTagProcessor

Attribute tag processors

A new element such as <bookshelf:new-arrivals/> that is replaced by markup

A subclass of AbstractElementTagProcessor

Element tag processors

To inspect or rewrite an element together with its whole body

A subclass of AbstractAttributeModelProcessor or AbstractElementModelProcessor

Element model processors

A new ${#bookshelf.isbn(...)} helper

An IExpressionObjectFactory returned from an IExpressionObjectDialect

Custom expression objects

An object every processor can reach

An IExecutionAttributeDialect

Execution attributes

To see or change every event of every template

A PreProcessor or a PostProcessor with an ITemplateHandler

Pre-processors and post-processors

The dialect in a Spring Boot application

A bean of type IDialect

Registering the dialect

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.

Table 2. The five kinds of dialect
Interface It contributes Notes

IProcessorDialect

Processors, found by element and attribute name. Methods: getPrefix(), getDialectProcessorPrecedence() and getProcessors(String dialectPrefix).

The only kind with a prefix. AbstractProcessorDialect implements it.

IPreProcessorDialect

Pre-processors, which see every event before any processor does. Methods: getDialectPreProcessorPrecedence(), getPreProcessors().

No prefix: pre-processors do not match on names.

IPostProcessorDialect

Post-processors, which see every event after all processors ran, just before the output is written. Methods: getDialectPostProcessorPrecedence(), getPostProcessors().

Analogous to the pre-processor dialect.

IExpressionObjectDialect

An IExpressionObjectFactory, from which the #name objects of expressions are built. Method: getExpressionObjectFactory().

The factory builds objects lazily, only for the names a template really uses.

IExecutionAttributeDialect

Execution attributes: named objects that every processor can read for the whole template execution. Method: getExecutionAttributes().

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).

classDiagram class IDialect { <<interface>> +getName() String } class IProcessorDialect { <<interface>> +getPrefix() String +getDialectProcessorPrecedence() int +getProcessors(String) Set } class IPreProcessorDialect { <<interface>> +getPreProcessors() Set } class IPostProcessorDialect { <<interface>> +getPostProcessors() Set } class IExpressionObjectDialect { <<interface>> +getExpressionObjectFactory() IExpressionObjectFactory } class IExecutionAttributeDialect { <<interface>> +getExecutionAttributes() Map } class AbstractProcessorDialect class BookshelfDialect IDialect <|-- IProcessorDialect IDialect <|-- IPreProcessorDialect IDialect <|-- IPostProcessorDialect IDialect <|-- IExpressionObjectDialect IDialect <|-- IExecutionAttributeDialect IProcessorDialect <|.. AbstractProcessorDialect AbstractProcessorDialect <|-- BookshelfDialect IPreProcessorDialect <|.. BookshelfDialect IPostProcessorDialect <|.. BookshelfDialect IExpressionObjectDialect <|.. BookshelfDialect IExecutionAttributeDialect <|.. BookshelfDialect class IProcessor { <<interface>> +getTemplateMode() TemplateMode +getPrecedence() int } class IElementProcessor { <<interface>> } class IElementTagProcessor { <<interface>> } class IElementModelProcessor { <<interface>> } class ICommentProcessor { <<interface>> } class ITextProcessor { <<interface>> } class ITemplateBoundariesProcessor { <<interface>> } class AbstractElementTagProcessor class AbstractAttributeTagProcessor class AbstractElementModelProcessor class AbstractAttributeModelProcessor IProcessor <|-- IElementProcessor IProcessor <|-- ICommentProcessor IProcessor <|-- ITextProcessor IProcessor <|-- ITemplateBoundariesProcessor IElementProcessor <|-- IElementTagProcessor IElementProcessor <|-- IElementModelProcessor IElementTagProcessor <|.. AbstractElementTagProcessor AbstractElementTagProcessor <|-- AbstractAttributeTagProcessor IElementModelProcessor <|.. AbstractElementModelProcessor AbstractElementModelProcessor <|-- AbstractAttributeModelProcessor IProcessorDialect ..> IProcessor : provides
Figure 1. How the dialects and processors relate

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.

Table 3. Processor kinds and their convenience superclasses
Interface Base class in org.thymeleaf.processor Receives

IElementTagProcessor

AbstractElementTagProcessor, AbstractAttributeTagProcessor (in .element)

One open or standalone tag. It cannot see the body, and it is never called for close tags.

IElementModelProcessor

AbstractElementModelProcessor, AbstractAttributeModelProcessor (in .element)

The whole element as an IModel: open tag, body and close tag.

ITemplateBoundariesProcessor

AbstractTemplateBoundariesProcessor (in .templateboundaries)

The template start and the template end events.

ITextProcessor

AbstractTextProcessor (in .text)

Each text event.

ICommentProcessor

AbstractCommentProcessor (in .comment)

Each comment event.

CDATA, DOCTYPE, XML declaration, processing instruction

Matching interfaces in .cdatasection, .doctype, .xmldeclaration and .processinginstruction

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.

RatingAttributeTagProcessor: bookshelf:rating
/** 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:

RatingAttributeTagProcessor.properties and RatingAttributeTagProcessor_es.properties
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.

StockAttributeTagProcessor: bookshelf:stock
/** 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):

Rendered output
<li class="book low-stock">x</li>

The structure handler of a tag processor (IElementTagStructureHandler) is the vocabulary of what a tag processor can ask for.

Table 4. The main actions of IElementTagStructureHandler
Method Effect

setLocalVariable, removeLocalVariable

Defines or removes a local variable for the rest of the element’s processing.

setAttribute, replaceAttribute, removeAttribute

Adds or changes an attribute, replaces one by another with a new name, or removes it.

setSelectionTarget, setInliner, setTemplateData

Changes the selection target (the object *{...} refers to), the inliner, or the template data in force.

setBody

Replaces the body with text or with a model. The boolean says whether the new body is processable.

insertBefore, insertImmediatelyAfter

Adds a model before the element (never processable) or right after it.

replaceWith

Replaces the whole element with text or with a model.

removeElement, removeTags, removeBody, removeAllButFirstChild

Removes the element, its two tags, its body, or everything but the first child.

iterateElement

Repeats the element for each item of a collection, as th:each does.

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.

NewArrivalsElementTagProcessor: bookshelf:new-arrivals
/** 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.

Table 5. The main IModelFactory methods
Method Creates

createModel(), createModel(ITemplateEvent)

An empty model, or a model that holds one event.

createOpenElementTag, createCloseElementTag, createStandaloneElementTag

Tags, with overloads that take one attribute, a map of attributes with their quoting, or flags such as synthetic and minimized.

createText, createComment

A text or a comment event. The text is written as it is: it is not escaped for you.

parse(TemplateData, String)

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.

PromotedModelProcessor: bookshelf:promoted
/** 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:

Rendered output, and the failure outside .catalog
<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.

InternalCommentProcessor
/** 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.

RenderedByBoundariesProcessor
/** 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>.

A test processor that writes an expression result with or without escaping
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:

Same value, written without and with HtmlEscape
<p>Tom & <b>Jerry</b></p>
<p>Tom &amp; &lt;b&gt;Jerry&lt;/b&gt;</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 and its factory: #bookshelf
/** 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.

A post-processor handler that tidies whitespace
/** 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);
        }
    }
}
A pre-processor handler that logs the template name
/** 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.

BookshelfDialect
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.

Table 6. Where the official examples fit
Official example What it shows On this page

Say Hello!, a hello dialect with a sayto attribute processor

The smallest useful dialect: an AbstractAttributeTagProcessor that sets a body, an AbstractProcessorDialect and registering it with addDialect on a Spring template engine, so that the standard attributes keep working.

Attribute tag processors, Registering the dialect

Say Hello Again!, the same dialect extended

Parsing and executing attribute values as Standard Expressions, and internationalizing the message with context.getMessage and per-processor .properties files.

Evaluating expressions inside a processor

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 Dialect class that declares them.

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.

Declaring the dialect as a bean
@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.

templates/extension/shelf.html
<!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:

ShelfController
@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:

Rendered output
<!DOCTYPE html>
<html>
<body>

<!-- visible comment -->
<section class="catalog">
<h1>Catalog</h1>
<ul class="new-arrivals"><li>Dune</li><li>Emma &lt;i&gt;</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 &amp; Jerry &lt;b&gt;</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.

A unit test with a standalone engine
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.

A Spring Boot test of the registration and the page
@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 parseExpression is the server-side template injection problem described in Template Security & XSS. This is why NewArrivalsElementTagProcessor reads limit as 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)}) gave 1 in 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) and replaceWith(model, true) hand the generated events back to the engine, which processes any th:* 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. Use false for anything that includes data.

Pitfalls

  • Forgetting the dialect prefix argument. A processor that hard-codes bookshelf stops working the day someone adds the dialect with another prefix. Pass dialectPrefix to the constructors and let the base class build the names.

  • setDialect instead of addDialect. setDialect replaces the dialects of the engine, so the standard attributes disappear and every th:* stays in the output untouched. addDialect keeps 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. SpringContextUtils and 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 TemplateProcessingException thrown 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.spring6 with Spring Boot 4, and there is no thymeleaf-spring7 artifact.