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, |
|
Choose the mode per template name |
|
|
Write |
Escaped element attributes: |
|
Add a custom element to a text template |
A dialect with an element processor for |
|
Code that runs in the application but not in a static preview |
|
|
Code that must exist in the prototype but never in the output |
|
|
A script that works when opened from disk and when rendered |
The whole textual syntax wrapped in comments: |
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 |
|---|---|---|
|
|
Bracket elements, parser-level comments |
|
|
Everything in TEXT, plus prototype-only comments, JavaScript string and JSON output, natural templates. |
|
|
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.htmlfiles). -
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*.txtormail/*.txtwork. -
setForceTemplateMode(true)makes the resolver’s own mode win over the file-name conventions. -
Spring Boot builds the main resolver for you; additional
ITemplateResolverbeans are picked up by itsSpringTemplateEngineand tried inorder. That is the cheapest way to add the textual modes to a Boot application.
@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:
@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})]
[/]
Source: Using Thymeleaf — Textual syntax
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.
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
Source: Using Thymeleaf — Textual syntax
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:
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
Source: Using Thymeleaf — Textual syntax
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>:
Escape: [[${v}]] raw: [(${v})]
Escape: <b>Tom & "Jerry"</b> raw: <b>Tom & "Jerry"</b>
Source: Using Thymeleaf — Text inlining
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.
[#th:block th:each="book : ${books}"]
- [#th:block th:utext="${book}" /]
[/th:block]
Rendered for the two books:
- Dune
- Solaris
Source: Using Thymeleaf — Textual syntax
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 (
<becomes<) -
in JAVASCRIPT mode, attribute values are JavaScript-unescaped
-
in CSS mode, attribute values are CSS-unescaped
[# th:if="${120<age}"]
Congratulations on your long membership!
[/]
[# th:if="${age < 18}"]
Junior membership applies.
[/]
With age set to 130:
Congratulations on your long membership!
In a pure text file the < 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:
<p th:inline="text">
[# th:if="${120<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:
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();
}
[#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:
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:
/*[- */
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:
/*[- 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:
/*[# 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:
: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:
@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, |
CSV export |
TEXT mode, one row per |
Generated JavaScript |
JAVASCRIPT mode, |
Generated CSS (themes) |
CSS mode, |
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. |
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:
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 |
|---|---|
|
A |
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. |
|
The template is in HTML mode and no |
A |
The textual resolver was not registered, or its |
|
|
|
Prototype-only comments exist in JAVASCRIPT and CSS only. In TEXT use the unconditional forms or |
|
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 |