Textual Template Modes

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 in a scratch Spring Boot application (Maven wrapper, MockMvc and a plain ITemplateEngine call, JDK 26 with --release 21); the output shown is the real output. The textual modes belong to the engine, so they work standalone, with Spring MVC and with Spring WebFlux alike. The listings use Spring MVC to show where the rendered text goes (a service that builds an email body, a controller that serves a generated script), and the same templates run unchanged with the plain TemplateEngine described on the standalone engine page: Standalone Engine & Configuration.

Thymeleaf has six template modes: HTML, XML, TEXT, JAVASCRIPT, CSS and RAW (which passes content through untouched). HTML and XML are the markup modes: a template is a tree of tags, and logic lives in attributes such as th:text and th:each. TEXT, JAVASCRIPT and CSS are the textual modes: a template is a stream of characters with no tags to hang attributes on, so the logic has to come from somewhere else. This page covers where it comes from: the inlined expressions [[...]] and [(...)], a bracket-based element syntax for control flow, and a set of comment conventions that let a script or style sheet stay valid and previewable without the engine. The inlining mechanics themselves (th:inline, the escaping rules of [[...]] against [(...)], JavaScript serialisation) have a page of their own; this page assumes you have met [[${x}]] and builds on it.

In a hurry: what to use for what

I need to…​ Use Section

Render a plain-text email, a CSV file or a config file

TEXT mode, [(${x})] for values and [# th:each="..."]...[/] for loops.

The textual syntax: elements in square brackets

Choose the mode per template name

setTextTemplateModePatterns, setJavaScriptTemplateModePatterns, setCSSTemplateModePatterns on a resolver.

The three textual modes and how a template gets one

Write < or > inside a condition without confusing an editor or a browser

Escaped element attributes: &lt; in TEXT, JavaScript escapes in JAVASCRIPT, CSS escapes in CSS.

Escaped element attributes

Add a custom element to a text template

A dialect with an element processor for TemplateMode.TEXT, used as [#prefix:name ...].

Extensibility: your own elements in text

Code that runs in the application but not in a static preview

/*[+ ... +]*/ in JAVASCRIPT and CSS modes.

Prototype-only comment blocks: adding code

Code that must exist in the prototype but never in the output

/*[- */ ... /* -]*/ in all three textual modes.

Parser-level comment blocks: removing code

A script that works when opened from disk and when rendered

The whole textual syntax wrapped in comments: /*[# th:if="..."]*/ ... /*[/]*/.

Natural JavaScript and CSS templates

The three textual modes and how a template gets one

The mode of a template decides which parser reads it and which escaping applies to the values written into it. The Standard Dialect processors exist for every mode, but the syntax differs.

Mode Typical files What it adds over plain inlining

TEXT

.txt, .csv, .conf, plain-text email bodies

Bracket elements, parser-level comments /*[- ... -]*/. No prototype-only comments.

JAVASCRIPT

.js files, <script> bodies

Everything in TEXT, plus prototype-only comments, JavaScript string and JSON output, natural templates.

CSS

.css files, <style> bodies

Everything in TEXT, plus prototype-only comments, CSS escaping, natural templates.

The same three modes are also what the engine switches to inside an HTML template when you declare th:inline="text", "javascript" or "css" on an element; the page on inlining shows that route. This page is about the other route: a file that is a textual template from its first character. How a resolver picks the mode:

  • A resolver has a default templateMode (HTML for a Spring-resource resolver, which is what Spring Boot configures for .html files).

  • It can also keep pattern sets per mode: setTextTemplateModePatterns, setJavaScriptTemplateModePatterns, setCSSTemplateModePatterns (and the HTML, XML and RAW variants). A template whose name matches a pattern gets that mode; the patterns support * wildcards, so *.txt or mail/*.txt work.

  • setForceTemplateMode(true) makes the resolver’s own mode win over the file-name conventions.

  • Spring Boot builds the main resolver for you; additional ITemplateResolver beans are picked up by its SpringTemplateEngine and tried in order. That is the cheapest way to add the textual modes to a Boot application.

A second resolver for the textual templates
@Configuration(proxyBeanMethods = false)
class TextTemplatesConfig {

    @Bean
    ITemplateResolver textualTemplateResolver(ApplicationContext context) {
        var resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(context);
        resolver.setPrefix("classpath:/templates/");
        resolver.setResolvablePatterns(Set.of("text/*", "js/*", "css/*"));
        resolver.setTextTemplateModePatterns(Set.of("*.txt", "*.csv", "*.conf"));
        resolver.setJavaScriptTemplateModePatterns(Set.of("*.js"));
        resolver.setCSSTemplateModePatterns(Set.of("*.css"));
        resolver.setCharacterEncoding("UTF-8");
        resolver.setCheckExistence(true);
        resolver.setOrder(3);
        return resolver;
    }
}

setResolvablePatterns keeps this resolver from claiming view names such as books/list, which must still go to the HTML resolver. Template names like text/overdue.txt keep their extension because the resolver has no suffix. A small service renders them to a String:

Rendering a text template to a String
@Service
public class TextRenderer {

    private final ITemplateEngine engine;

    TextRenderer(ITemplateEngine engine) {
        this.engine = engine;
    }

    public String render(String template, Map<String, Object> variables) {
        var context = new Context(Locale.ENGLISH, variables);
        return engine.process(template, context);
    }
}

The textual syntax: elements in square brackets

Without tags there is nowhere to put th:each. So the textual modes define an element syntax built on square brackets instead of angle brackets. An element is opened as [#name attributes], closed as [/name], and a standalone element is written [#name attributes /]. Attributes are quoted, and the elements must be balanced: this is closer to XML than to HTML.

The Standard Dialect ships a processor for exactly one such element, th:block, and allows its name to be omitted, so these three templates mean the same thing:

[#th:block th:each="book : ${books}"]
 - [#th:block th:utext="${book}" /]
[/th:block]
[# th:each="book : ${books}"]
 - [# th:utext="${book}" /]
[/]
[# th:each="book : ${books}"]
 - [(${book})]
[/]

The element [# th:utext="${book}" /] is exactly an inlined unescaped expression, which is why the shortest form is the usual one. The th: attributes inside these elements are processed by the same Standard Dialect processors as in HTML, so the attributes described in the pages on iteration and conditionals (th:each, th:if, th:unless, th:switch, th:case, th:with) work on [# ...] elements.

A plain-text email (text/overdue.txt)
Dear [(${member})],

These books are due back:
[# th:each="book : ${books}"]
 - [(${book})]
[/]
Thanks,
  The Bookshelf team

Rendered with member set to Ada and books set to Dune and Solaris:

Dear Ada,

These books are due back:

 - Dune
 - Solaris

Thanks,
  The Bookshelf team

Look at the blank lines: they are part of the text. The line holding [# th:each=...] and the line holding [/] each end with a newline, and those newlines stay in the output, so the template above produces an extra blank line before the first item, one between iterations and one before the closing line. When the exact layout matters (CSV, a config file, a fixed-width report), put the opening and closing elements at the end of neighbouring content lines instead of on lines of their own:

The compact form (text/compact.txt)
Dear [(${member})],
These books are due back:[# th:each="book : ${books}"]
 - [(${book})][/]
Thanks,
  The Bookshelf team

Rendered:

Dear Ada,
These books are due back:
 - Dune
 - Solaris
Thanks,
  The Bookshelf team

Escaped and unescaped output in text

The two inlined forms still mean escaped and unescaped output, and which you want depends on where the text goes. For a plain-text email or a configuration file there is nothing to protect from, and [(...)] writes the value as is. If the same text will later be embedded in HTML, a mail client’s HTML view or a log viewer that renders markup, escape it. The values below are <b>Tom & "Jerry"</b>:

Escaped against unescaped output in TEXT mode (text/esc.txt)
Escape: [[${v}]] raw: [(${v})]
Escape: &lt;b&gt;Tom &amp; &quot;Jerry&quot;&lt;/b&gt; raw: <b>Tom & "Jerry"</b>

So [[...]] in TEXT mode applies HTML escaping, not "no escaping". If the text is a CSV cell or a shell command and not HTML at all, neither form escapes the right characters (quotes for CSV, spaces for a shell): format the value in Java, or with a utility such as #strings, before it reaches the template.

The verbose element form

[#th:block ...] is the long spelling, useful when you want to be explicit about which element you mean. The element is a synthetic one: nothing of it is written, only its content and the effect of its attributes. The empty-name form [# ... ] is just an abbreviation of it.

The verbose form (text/verbose.txt)
[#th:block th:each="book : ${books}"]
 - [#th:block th:utext="${book}" /]
[/th:block]

Rendered for the two books:

 - Dune
 - Solaris

Escaped element attributes

An attribute inside a textual element is written in a file whose mode may be different from the file that finally gets served. The typical case: a th:inline="text" block inside an HTML template, opened from disk as a prototype. A raw < in [# th:if="${120<user.age}"] would make the browser take <user.age for the name of an open tag. Since Thymeleaf 3.0 the attributes of textual elements are therefore unescaped by the engine according to the mode of the template they belong to:

  • in TEXT mode, attribute values are HTML-unescaped (&lt; becomes <)

  • in JAVASCRIPT mode, attribute values are JavaScript-unescaped

  • in CSS mode, attribute values are CSS-unescaped

Escaped comparison in a TEXT template (text/escaped.txt)
[# th:if="${120&lt;age}"]
Congratulations on your long membership!
[/]
[# th:if="${age &lt; 18}"]
Junior membership applies.
[/]

With age set to 130:

Congratulations on your long membership!

In a pure text file the &lt; is never needed, and writing it makes the template harder to read; the escape exists for text that lives inside another document. The scenario it protects, an HTML page that contains a text block, looks like this:

The same element inside an HTML template (page/age.html)
<p th:inline="text">
  [# th:if="${120&lt;age}"]
  Congratulations on your long membership!
  [/]
</p>

Rendered with age set to 130:

<p>

  Congratulations on your long membership!

</p>

Extensibility: your own elements in text

The element syntax is as extensible as the tag syntax. A dialect can contribute element processors that apply to TemplateMode.TEXT, JAVASCRIPT or CSS, give them a prefix, and then be used as [#prefix:name attr="..."]...[/prefix:name]. The official example is a [#myorg:dosomething myorg:importantattr="211"]some text[/myorg:dosomething] element. Writing one is the same job as writing a processor for HTML (the guide on extending Thymeleaf covers it): subclass AbstractElementTagProcessor, choose the template mode, and declare the processor in an AbstractProcessorDialect. The dialect below adds a shout:upper element to TEXT templates:

A custom element for TEXT mode
public class ShoutDialect extends AbstractProcessorDialect {

    public ShoutDialect() {
        super("Shout dialect", "shout", StandardDialect.PROCESSOR_PRECEDENCE);
    }

    @Override
    public Set<IProcessor> getProcessors(String dialectPrefix) {
        return Set.of(new ShoutElementProcessor(dialectPrefix));
    }

    static class ShoutElementProcessor extends AbstractElementTagProcessor {

        ShoutElementProcessor(String dialectPrefix) {
            super(TemplateMode.TEXT, dialectPrefix, "upper", true, null, false, 1000);
        }

        @Override
        protected void doProcess(ITemplateContext context, IProcessableElementTag tag,
                IElementTagStructureHandler structureHandler) {
            String value = tag.getAttributeValue("text");
            structureHandler.replaceWith(value == null ? "" : value.toUpperCase(), false);
        }
    }
}

Registering the dialect as a Spring bean is enough in Spring Boot; the auto-configured engine adds every IDialect bean:

@Bean
ShoutDialect shoutDialect() {
    return new ShoutDialect();
}
Using the element (text/shout.txt)
[#shout:upper text="overdue" /]: please return [(${count})] books.

Rendered with count set to 2:

OVERDUE: please return 2 books.

The processor in this example is tied to TemplateMode.TEXT; to serve JAVASCRIPT and CSS as well, register one processor per mode. A processor registered only for HTML does not run in the textual modes, so the pages that extend Thymeleaf are where to look for the dialect basics, the processor precedence rules and the other kinds of processors.

Prototype-only comment blocks: adding code

The JAVASCRIPT and CSS modes (not TEXT) recognise the special comment /*[+ ... +]*/. When the file is opened as a plain script or style sheet, the browser sees a comment and skips it. When Thymeleaf processes the file it removes the comment markers and keeps what is inside, so the code becomes live:

Code that only exists in the working application (js/shelf.js)
var shelf = function() {

    var name = [[${shelfName}]];

    /*[+
    var mode = "Running as a working application";
    +]*/

    ...
};

The statement var mode = ... is a comment in the browser’s static view and a normal statement in the output:

var shelf = function() {

    var name = "Sci-Fi \"Classics\"";


    var mode = "Running as a working application";


    ...
};

Expressions inside the block are evaluated too, so the block can use [[${...}]]. Typical uses are a fragment of initialisation that only exists in production or a call that needs the real session, in a script that would otherwise load in a designer’s browser with no backend.

Parser-level comment blocks: removing code

The opposite convention works in all three textual modes (TEXT, JAVASCRIPT and CSS). Code between /*[- */ and /* -]*/ is removed by the parser before any processing happens, so it exists only in the static file. Use it for placeholder content a designer needs to see:

Removed content in JAVASCRIPT mode (js/shelf.js)
    /*[- */
    var mode = "Shown only when the file is opened statically";
    /* -]*/

A comment of the form /*[- ... -]*/ removes just that comment, which is also the tidy way to leave a note in a text template that must not reach the output:

A comment that never reaches the output (text/comments.txt)
/*[- Note the member is read from the model, which must be set -]*/
Welcome back, [(${member})]!
/*[-*/
This line only appears when the file is opened as a plain file.
/*-]*/
Enjoy your reading.

Rendered with member set to Ada:

Welcome back, Ada!

Enjoy your reading.

The pair mirrors the HTML <!--/* ... */--> blocks: the parser-level comment is removed before the processors run, while the prototype-only one is kept and uncommented. Neither appears in the page an end user sees.

Natural JavaScript and CSS templates

The inlining chapter introduced the trick of wrapping an inlined expression in a comment and following it with a literal that the static file shows. The textual syntax goes further: the whole element syntax can be wrapped in comments, so a script containing control flow is still plain, valid JavaScript when opened from disk:

/*[# th:if="${user.admin}"]*/
     alert('Welcome admin');
/*[/]*/

Opened statically, the alert is valid JavaScript and runs. Rendered, it stays for admins and disappears for everyone else. Thymeleaf converts this at parse time into the plain [# th:if="${user.admin}"] ... [/] form. The same wrapper iterates, which is how a script can be written with a sample row and rendered with real ones:

A loop in a natural JavaScript template (js/shelf.js)
    /*[# th:each="greeting : ${greetings}"]*/
    console.log(/*[[${greeting}]]*/ "Hello");
    /*[/]*/

With greetings set to Hello and Hola, rendered:

    console.log("Hello");
    console.log("Hola");

One rule differs from the inlined expressions: an inlined output expression in a comment also removes the static literal that follows it, which is why console.log(/*[[${greeting}]]*/ "Hello") prints the greeting and not both. Wrapping an element in comments does not clean the rest of its line; that clean-up is reserved for inlined output expressions only.

CSS templates work the same way. Because [[...]] in the CSS modes escapes CSS characters, a colour such as #0b6e4f comes out as \#0b6e4f when written with the escaped form. Use the unescaped [(...)] for values you trust, such as an accent colour from your own configuration, and keep the escaped form for text:

A natural CSS template (css/theme.css)
:root {
    --accent: [(${accent})];
}

a { color: /*[[${accent}]]*/ #333; }

/*[# th:if="${accent == '#0b6e4f'}"]*/
.badge { border-color: green; }
/*[/]*/

Rendered with accent set to #0b6e4f:

:root {
    --accent: #0b6e4f;
}

a { color: \#0b6e4f; }


.badge { border-color: green; }

The \#0b6e4f in the second rule is a CSS-escaped hash, so the browser does not read it as a colour and ignores the declaration. That is the reason --accent above uses the unescaped form: for a trusted token such as a colour from your own configuration use [(...)], and keep [[...]] for text. Never feed an untrusted value to [(...)] in CSS or JavaScript.

Serving a generated script or style sheet

A rendered script is a normal HTTP response whose body happens to come from a template. In Spring MVC the controller renders to a String and declares the media type; because the content varies per request or per deployment, do not let proxies cache it by accident:

Serving generated JavaScript and CSS
@RestController
class AssetController {

    private final TextRenderer renderer;

    AssetController(TextRenderer renderer) {
        this.renderer = renderer;
    }

    @GetMapping(value = "/generated/shelf.js", produces = "text/javascript")
    ResponseEntity<String> script() {
        String body = renderer.render("js/shelf.js", Map.of(
                "shelfName", "Sci-Fi \"Classics\"",
                "greetings", List.of("Hello", "Hola")));
        return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body);
    }

    @GetMapping(value = "/generated/theme.css", produces = "text/css")
    String stylesheet() {
        return renderer.render("css/theme.css", Map.of("accent", "#0b6e4f"));
    }
}

The string Sci-Fi "Classics" came out as the JavaScript string literal "Sci-Fi \"Classics\"": in JAVASCRIPT mode [[...]] writes a JavaScript literal, with quotes and escapes, not the raw text. That is what makes var name = [[${shelfName}]]; safe where building the string by hand would not be. The Spring Security default requires a login for these URLs, so a script that has to load on the login page needs its path permitted explicitly (the pages on Spring Security cover how).

Use cases

Use case Notes

Plain-text email

TEXT mode, [(...)], loops with [# th:each]. Pair it with an HTML template for the multipart/alternative body. The page on email covers sending.

CSV export

TEXT mode, one row per [# th:each] iteration, with the opening and closing elements on content lines so no blank lines appear. Quote cells in Java or in the template.

Generated JavaScript

JAVASCRIPT mode, [[...]] for JSON-safe values, natural templates so the file is previewable.

Generated CSS (themes)

CSS mode, [(...)] for trusted tokens such as colours, comment-wrapped [# th:if] for optional rules.

Configuration files

TEXT mode, for nginx, systemd or properties files that are produced per environment.

Prompts, reports, any other text

Anything line-oriented; the same engine, caches and message resolvers apply.

A CSV export and a configuration file in TEXT mode
title,author,due
[# th:each="loan : ${loans}"]"[(${loan.title})]","[(${loan.author})]",[(${#temporals.format(loan.due, 'yyyy-MM-dd')})]
[/]
server_name [(${host})];
[# th:each="path : ${paths}"]location [(${path})] { proxy_pass http://backend; }
[/]

Rendered with two loans, and with host books.example.org and paths /api and /static:

title,author,due
"Dune","Frank Herbert",2026-11-03
"Solaris","Stanislaw Lem",2026-11-09
server_name books.example.org;
location /api { proxy_pass http://backend; }
location /static { proxy_pass http://backend; }

Restricted expression mode in TEXT mode

The tutorial notes that the TEXT mode is subject to stricter expression evaluation restrictions than the markup modes, and Appendix D of the tutorial lists TEXT-mode output among the contexts where the restricted mode applies. In that mode, instantiating objects with new and referencing static classes with T(...) are forbidden:

A forbidden expression
Env: [(${T(java.lang.System).getProperty("user.name")})]
org.thymeleaf.exceptions.TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is forbidden in this context (template: "text/restricted.txt" - line 1, col 8)

Restricted mode is a safety net, not a licence to render attacker-written templates: keep the template text under your control. The page on template security has the full list of what the restricted mode forbids and the Thymeleaf CVEs fixed in 3.1.4 and 3.1.5.

Pitfalls

Symptom Cause and fix

TextParseException: Malformed template: element "" is never closed

A [# ...] without its [/]. The textual syntax needs fully balanced elements and quoted attributes. The empty name in the message is the abbreviated th:block.

Blank lines in the output of a CSV or config file

The opening and closing element lines keep their newlines. Put the elements at the end of content lines, as in the compact form.

[# ...] printed literally in an HTML page

The template is in HTML mode and no th:inline is declared. Use a textual mode (a resolver pattern or th:inline) for that file or element.

A .txt template is looked up by the HTML resolver

The textual resolver was not registered, or its resolvablePatterns or order hide it. Check the order and use setCheckExistence(true) on the textual resolver.

\# in CSS output

[[...]] escapes in CSS mode. Use [(...)] for trusted tokens and escape only text.

/*[+ ... +]*/ ignored in a text file

Prototype-only comments exist in JAVASCRIPT and CSS only. In TEXT use the unconditional forms or th:if.

T(...) or new rejected in a text template

Restricted expression mode. Compute the value in Java and expose it as a model attribute.

A dialect element works in HTML but not in text

The processor is registered for TemplateMode.HTML only. Register one for the textual mode too.