Inlining
|
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-07, and every example was compiled and run on that stack (Spring Boot 4.1.1 with Spring MVC, rendered through
MockMvc; the output shown is what the engine produced).
Inlining is a feature of the Standard Dialect itself, so everything here applies to Thymeleaf standalone, with Spring
MVC and with Spring WebFlux alike. Inlining lets you write an expression directly in the text of the page ([[${name}]])
instead of wrapping it in an element with th:text. It is compact, and it is the only way to get a server value into a <script>
or <style> block, which is why it is also where the most dangerous template mistakes (script injection through a badly
escaped value) are made. This page explains the syntaxes, what each inlining mode escapes, how objects become JavaScript
literals, and the safe way to give server data to JavaScript frameworks such as Alpine.js.
In a hurry: what to use for what
| You want to output… | Use | Why |
|---|---|---|
A value as text in an HTML body |
|
HTML-escaped; works anywhere in a tag body, no extra attribute needed. |
Trusted markup that must not be escaped |
|
Not escaped, and evaluated in restricted mode; never feed it user data. |
A literal |
|
Turns inlining off for that element’s body. |
A server value inside a |
|
The value becomes a valid JavaScript literal (quoted and escaped, or serialised as JSON). |
A script that must also run when the file is opened statically |
|
A JavaScript natural template: the comment form is ignored by the browser, the default after it is replaced. |
A value inside a |
|
Escaped as a CSS identifier or string. |
Server data for Alpine.js |
A |
Never splice a value into an |
The same choice as a decision flow:
HTML-escaped"] where -->|"Trusted markup"| utx["Bracket-parenthesis or th:utext
not escaped, trusted data only"] where -->|"Script block"| js["th:inline javascript
JavaScript literal or JSON"] where -->|"Style block"| css["th:inline css
CSS identifier or string"] where -->|"Alpine or other framework"| fw["Alpine.data, data attribute
or JSON data island"] js --> nat["Add a comment form
to keep the file natural"]
Expression inlining: [[...]] and [(...)]
Any expression that is valid in th:text or th:utext can be written between double square brackets or between square
bracket and parenthesis pairs, anywhere in the body of a tag. The escaped form [[${...}]] corresponds to th:text and
the unescaped form [(${...})] to th:utext. Inlining of text is active by default in the body of every tag, so there is
nothing to enable.
The controller puts a message and a reader name in the model:
@GetMapping("/inlining/text")
String text(Model model) {
model.addAttribute("msg", "This is <b>great!</b>");
model.addAttribute("reader", "Ada Lovelace");
return "inlining/text";
}
and the template uses the two syntaxes:
<p>Hello, [[${reader}]]!</p>
<p>Escaped: "[[${msg}]]"</p>
<p>Unescaped: "[(${msg})]"</p>
The rendered output (run on Boot 4.1.1) shows the difference:
<p>Hello, Ada Lovelace!</p>
<p>Escaped: "This is <b>great!</b>"</p>
<p>Unescaped: "This is <b>great!</b>"</p>
Three rules follow from how the engine works:
-
Inlining is applied to the text of an element, never to the markup of the tag itself.
<a title="[[${reader}]]">is left exactly as written: the double brackets in an attribute value are plain characters. Useth:title(orth:attr) for attributes. -
The text of
<script>and<style>elements is also treated as a body, so withoutth:inlinethe default HTML-escaping text inlining runs on it. A<script>containingvar a = "[[${reader}]]";and the readerAda <Lovelace>is rendered asvar a = "Ada <Lovelace>";, which is rarely what you want. Use JavaScript or CSS inlining, described below. -
[(...)]is evaluated in the restricted expression mode of the Thymeleaf 3.1 tutorial’s Appendix D, liketh:utextand__...__preprocessing. It rejects${param.*}, object instantiation, static class access and some context utility objects; the template above with[(${param.q})]throws a template exception (the message is "Instantiation of new objects and access to static classes or parameters is forbidden in this context") when the page is rendered. These restrictions are defence in depth, not a replacement for validating input.
Inlining versus natural templates
Inlining writes less than th:text, so why does the tutorial keep th:text as the default? Because of natural templates
(see the page on the Thymeleaf architecture): a page that uses th:text still shows Sebastian when a designer opens the file
in a browser, whereas inlined expressions are shown verbatim.
<!-- natural: static prototype shows "Sebastian", the server shows the real name -->
<p>Hello, <span th:text="${session.user.name}">Sebastian</span>!</p>
<!-- inlined: opened statically it shows the placeholder expression text -->
<p>Hello, [[${session.user.name}]]!</p>
The trade-off is therefore design usefulness against brevity. A pragmatic rule: use attributes (th:text, th:href) for
anything a designer will open statically, inlining for text that mixes literal words and values (an e-mail-like sentence, a
confirmation message), and the comment forms described later in this page for JavaScript and CSS, which keep those blocks
natural too. The second Thymeleaf mechanism for keeping a page natural when the logic is not in the markup at all, decoupled
logic, is covered on its own page in this section.
Disabling inlining with th:inline="none"
When the output legitimately contains [[ or [(, such as a nested array literal in a text, put th:inline="none" on the
element. Its body is then output as written.
<p th:inline="none">A double array looks like this: [[1, 2, 3], [4, 5]]!</p>
The element is rendered without the th:inline attribute and with its text untouched:
<p>A double array looks like this: [[1, 2, 3], [4, 5]]!</p>
th:inline="none" is also the right switch for a <script> that contains client-side templating syntax of its own (for
example a Mustache or Vue string with double square brackets in it) which the engine must not touch.
Text inlining: th:inline="text"
The default inlining of a body only evaluates expressions. th:inline="text" must be enabled explicitly and does more: the
body is processed as a template in the TEXT template mode, so besides [[...]] and [(...)] it supports the textual
syntax with logic, such as [# th:each=...]...[/]. The syntax itself, its modes and its use for e-mails, CSV and other plain
output are described on the page about the textual template modes; here is the minimal form.
<pre th:inline="text">
Reader: [[${reader}]]
[# th:each="b : ${books}"]
- [[${b}]]
[/]
</pre>
Source: Using Thymeleaf — Text inlining
With reader set to Ada Lovelace and books to the list Dune, Emma, the output was:
Reader: Ada Lovelace
- Dune
- Emma
Source: Using Thymeleaf — Text inlining
A restriction worth knowing: in the TEXT template mode all output expressions are evaluated in restricted mode, escaped or
not, because the engine cannot know where the text will end up. A text-inlined body that reads [[${param.q}]] fails with the
same "forbidden in this context" error shown above, while the same expression in an ordinary HTML body is allowed.
JavaScript inlining
th:inline="javascript" makes the body of a <script> be processed as if it were a template in the JAVASCRIPT template
mode. Its output expressions are not merely HTML-escaped: they are written as valid JavaScript literals. A string is quoted
and its content escaped, a number is written bare, null stays null, and objects and collections are serialised.
The controller in the Bookshelf scenario supplies a hostile name, a record, a map and a list:
public record BookSummary(Long id, String title, List<String> authors, LocalDate published, boolean available) {
}
@GetMapping("/inlining/script")
String script(Model model) {
BookSummary book = new BookSummary(7L, "Dune", List.of("Frank Herbert"), LocalDate.of(1965, 8, 1), true);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", 42);
stats.put("featured", book.title());
model.addAttribute("reader", "Sebastian \"Fruity\" O'Neil </script><script>alert(1)</script>");
model.addAttribute("book", book);
model.addAttribute("stats", stats);
model.addAttribute("tags", List.of("sci-fi", "classic"));
model.addAttribute("pageSize", 20);
return "inlining/script";
}
The template uses the escaped bracket form for each value. The unescaped form (last line) is shown only to demonstrate what not to do:
<script th:inline="javascript">
var name = [[${reader}]];
var size = [(${pageSize})];
var book = /*[[${book}]]*/ null;
var stats = /*[[${stats}]]*/ {};
var tags = /*[[${tags}]]*/ [];
var path = /*[[@{/books}]]*/ '/books';
var nothing = /*[[${missing}]]*/ 'dflt';
var flag = /*[[${book.available}]]*/ false;
var broken = [(${reader})];
</script>
and the rendered script (Boot 4.1.1, Thymeleaf 3.1.5, Jackson 3 on the classpath) was:
<script>
var name = "Sebastian \"Fruity\" O'Neil <\/script><script>alert(1)<\/script>";
var size = 20;
var book = {"id":7,"title":"Dune","authors":["Frank Herbert"],"published":"1965-08-01","available":true};
var stats = {"total":42,"featured":"Dune"};
var tags = ["sci-fi","classic"];
var path = "\/books";
var nothing = null;
var flag = true;
var broken = Sebastian "Fruity" O'Neil </script><script>alert(1)</script>;
</script>
Things to read in that output:
-
The string with the double quotes and the closing
</script>tag became a single, well-formed JavaScript string. The</sequence is rewritten as<\/so the browser’s HTML parser cannot end the script element early; this, together with the escaping of quotes and backslashes, is what makes JavaScript inlining safe for untrusted text. -
/*[[${missing}]]*/ 'dflt'printednull, not the default after the comment: the default after the comment is only for the static prototype. If a value may be absent, handlenullin the script. -
@{/books}is a legal expression too (a URL, written as a JavaScript string). The context path and URL rewriting apply. -
The last line, with the unescaped
[(...)], is broken JavaScript and a script injection: the value is pasted verbatim. Use the unescaped form only for fragments that you build yourself, for example to append a value to a JavaScript identifier, and never with data that came from a user. It is additionally evaluated in restricted mode. -
The record serialises as an object with its component names (
id,title, …), and aLocalDateas an ISO string, with the Jackson 3 serialiser used here.
JavaScript natural templates
Wrapping the escaped inlined expression in a JavaScript comment, /*[[${x}]]*/ default, keeps the file valid, executable
JavaScript when it is opened without a server. Thymeleaf ignores everything after the comment up to the end of the expression
(the semicolon, comma or closing bracket) and substitutes the value; a browser sees only a comment followed by the default.
<script th:inline="javascript">
var username = /*[[${reader}]]*/ "Gertrud Kiwifruit";
var size = /*[[${pageSize}]]*/ 10;
</script>
Rendered on the server the default values disappear (the first line gives the same quoted, escaped string as above, the second
var size = 20;), and a designer who opens the template statically gets a working script using "Gertrud Kiwifruit" and 10.
The general machinery behind this, including /*[# th:if=...]*/ ... /*[/]*/ blocks and the /*[+ ... +]*/ and
/*[- ... -]*/ comment forms, is covered on the page about the textual template modes.
Advanced inlined evaluation and JavaScript serialisation
JavaScript inlining is not limited to strings. The engine writes strings, numbers, booleans, arrays, collections, maps and
beans (objects with getters) in JavaScript syntax. The conversion is performed by an implementation of the interface
org.thymeleaf.standard.serializer.IStandardJavaScriptSerializer, which is configured on the StandardDialect of the engine.
The default implementation, StandardJavaScriptSerializer, checks the classpath once at start-up and picks, in this order
(checked against the 3.1.5 class files):
-
Jackson 2, when
com.fasterxml.jackson.databind.ObjectMapperis present; -
Jackson 3, when
tools.jackson.databind.ObjectMapperis present; -
otherwise a built-in serialiser that covers common cases with less flexibility.
Thymeleaf 3.1.5 supports Jackson 3; Spring Boot 4 brings Jackson 3 to the classpath by default, so a Boot 4 application
without extra dependencies uses the Jackson 3 branch above. An application that still carries Jackson 2 databind (for example
through a library that depends on it) gets the Jackson 2 branch instead, because it is checked first. In both cases the
serialiser applies Thymeleaf’s own escaping on top of JSON (the <\/ rewriting shown earlier).
The practical difference between the serialisers is the shape of values that are not plain beans. The same record rendered by the built-in serialiser and by Jackson 3 (run in the same scratch project) gave:
// built-in: java.time.LocalDate treated as a bean
{"id":7,"title":"Dune","authors":["F"],"published":{"chronology":{"calendarType":"iso8601","id":"ISO","isoBased":true},"dayOfMonth":1,"dayOfWeek":"SUNDAY","dayOfYear":213,"era":"CE","leapYear":false,"month":"AUGUST","monthValue":8,"year":1965},"available":true}
// Jackson 3
{"id":7,"title":"Dune","authors":["F"],"published":"1965-08-01","available":true}
Both handled the record (its components were written by name), so records work with and without Jackson. Dates are where they
diverge, which is the main reason to keep a Jackson on the classpath. Since the page’s data comes from a view model you control,
prefer to pass a small purpose-built record or map to the template instead of a JPA entity: serialising an entity can walk lazy
associations (a LazyInitializationException or an N+1 query storm during rendering) and can leak fields that the page does not
need, such as an e-mail address or an internal flag.
To replace the serialiser, set it on the dialect before the first template is processed with
StandardDialect.setJavaScriptSerializer(...) (SpringStandardDialect extends it). Spring Boot builds the engine for you, so a
BeanPostProcessor over the SpringTemplateEngine is a simple hook. The example below forces the built-in serialiser (it is
guarded by a profile so it does not affect the other examples):
@Configuration
@Profile("builtin-js")
class JavaScriptSerializerConfig {
@Bean
static BeanPostProcessor javaScriptSerializerCustomizer() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof SpringTemplateEngine engine) {
engine.getDialects().stream()
.filter(StandardDialect.class::isInstance)
.map(StandardDialect.class::cast)
.forEach(d -> d.setJavaScriptSerializer(new StandardJavaScriptSerializer(false)));
}
return bean;
}
};
}
}
With the profile builtin-js active, the page above rendered var book with the expanded date object shown for the built-in
serialiser, and var path = "/books"; (the built-in serialiser does not escape the slash as \/). The constructor argument
false of StandardJavaScriptSerializer means "do not use Jackson even if it is present", and true is the default behaviour.
If you write your own IStandardJavaScriptSerializer, you take over the escaping: it must make the output safe inside a script
element (at least the </ sequence), or you reintroduce the injection that the default prevents.
CSS inlining
th:inline="css" does the same for <style>, with CSS-specific escaping. An escaped expression is written as a CSS
identifier, so spaces and other special characters are backslash-escaped.
<style th:inline="css">
.[[${classname}]] {
text-align: [[${align}]];
}
</style>
Source: Using Thymeleaf — CSS inlining
With classname = 'main elems' and align = 'center' the rendered stylesheet was:
<style>
.main\ elems {
text-align: center;
}
</style>
Source: Using Thymeleaf — CSS inlining
CSS natural templates
As in JavaScript, wrapping the expression in a comment keeps the stylesheet valid and showable without a server. The text after the comment is the static default.
<style th:inline="css">
.main\ elems {
text-align: /*[[${align}]]*/ left;
}
</style>
The output of this block was text-align: center;, the same as the non-comment form. CSS inlining is meant for values that
are really dynamic (a theme colour from configuration, a per-tenant class name); for ordinary styling keep the rules in static
stylesheets, and switch classes with th:class or th:classappend instead.
Passing server data to Alpine.js and other scripts safely
Client frameworks that read their state from an attribute, such as Alpine.js with x-data, tempt developers to build the
JavaScript expression with string concatenation in a Thymeleaf attribute. The following pattern is unsafe even though
th:attr HTML-escapes its value:
<div th:attr="x-data=|{ name: '${reader}' }|">...</div>
With a name such as O'Neil'}; alert(1); //, the engine produced:
<div x-data="{ name: 'O'Neil'}; alert(1); //' }">unsafe</div>
HTML escaping protects the attribute, not the JavaScript inside it. The browser decodes each ' back to an apostrophe
before Alpine evaluates the string, so the user’s text closes the string literal and injects code. The value needs
JavaScript escaping, and that is exactly what JavaScript inlining provides. There are three safe designs.
Alpine.data with JavaScript inlining
Register the component in a script block that uses JavaScript inlining, and reference it by name from the markup. The data is serialised as a JavaScript literal, so a hostile string stays a string.
<div x-data="profile">...</div>
<script th:inline="javascript">
document.addEventListener('alpine:init', () => {
Alpine.data('profile', () => ({
name: /*[[${reader}]]*/ 'Ada',
book: /*[[${book}]]*/ null
}));
});
</script>
Source: Alpine.js — Alpine.data
The rendered script (same hostile name as above) was:
<div x-data="profile">...</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('profile', () => ({
name: "O'Neil'}; alert(1); \/\/",
book: {"id":7,"title":"Dune","authors":["Frank Herbert"],"published":"1965-08-01","available":true}
}));
});
</script>
Source: Alpine.js — Alpine.data
The whole attack string is data in a string literal. This keeps the template natural (the defaults after the comments make the script run statically) and is the best choice when the component has a lot of state or behaviour.
data-* attributes
For one or two scalar values, carry them in a data-* attribute with th:data-* and read them in the component. Attribute
values are only HTML-escaped, which is enough because they are never evaluated as code: Alpine reads them as strings through
$el.dataset.
<div x-data="{ name: $el.dataset.name }" th:data-name="${reader}">...</div>
Source: Alpine.js — x-data
The output keeps the hostile text as inert attribute content:
<div x-data="{ name: $el.dataset.name }" data-name="O'Neil'}; alert(1); //">...</div>
Source: Alpine.js — x-data
A JSON data island
For a larger structure, put JSON in a script element whose type the browser does not execute, filled by JavaScript inlining,
and parse it in the component. th:inline="javascript" writes valid JSON for strings, numbers, booleans, lists, maps and
records, and the comment form keeps the script natural:
<script type="application/json" id="book-data" th:inline="javascript">/*[[${book}]]*/ {}</script>
<div x-data="{ book: JSON.parse(document.getElementById('book-data').textContent) }">...</div>
The rendered island was {"id":7,"title":"Dune","authors":["Frank Herbert"],"published":"1965-08-01","available":true} inside
the script element. The <\/ rewriting also applies, so a value cannot close the element.
The same three patterns apply to any script that needs server data: Stimulus values, vanilla fetch helpers, chart libraries.
If the application uses a strict Content Security Policy, an inline script needs a nonce (or an external script file) in every
variant; the page on template security covers CSP with Thymeleaf, and the page on Alpine.js and web components uses these
patterns inside complete components.
Pitfalls
| Mistake | Symptom | Fix |
|---|---|---|
|
Value is HTML-escaped ( |
Add |
|
Broken JavaScript or script injection |
Use the escaped form |
|
Printed verbatim |
Use |
Putting a value in an |
HTML-escaping is undone by the browser, so quotes break out of the string |
Use |
Expecting the default after the comment to be used at run time |
|
The default is for static prototypes only; handle |
Serialising a JPA entity |
|
Pass a record or map built for the page. |
Relying on the date format without Jackson |
|
Keep Jackson 2 or Jackson 3 on the classpath, or convert dates to strings first. |
Custom |
Script element can be closed by data |
Delegate to |
|
Template processing exception: forbidden in this context |
TEXT mode restricts every output expression; read the value in the controller and put it in the model. |
Literal double square brackets in text, such as a nested array |
Parsed as an expression |
Wrap the element with |