Skip to content
CalliCoder

Server-Side Templating in Spring Boot with Thymeleaf

Spring Boot 13 min read

Why templates stop reloading the moment you build a jar, the difference between th:text and th:utext that decides whether you have an XSS hole, and the fragment mechanism that replaces copy-pasted layout.

Thymeleaf is the default template engine in Spring Boot, and most of what goes wrong with it is not syntax. It is templates that stop reloading, a static resource that 404s because it was addressed with a relative path, and th:utext used where th:text was meant.

Written against Spring Boot 3.2, Thymeleaf 3.1 and Java 17.

Dependencies and where files go

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

The starter fixes two conventions, and neither is configurable by accident:

src/main/resources/
├── templates/          # .html resolved by name from a controller
│   ├── articles.html
│   └── fragments/layout.html
└── static/             # served at the context root: /css/main.css
    └── css/main.css

A file in static/ is reachable at its path below static, so static/css/main.css is /css/main.css. A file in templates/ is never served directly. It is only reachable through a controller that returns its name.

A controller and a model

@Controller
@RequestMapping("/articles")
public class ArticleController {

    private final ArticleRepository repository;

    ArticleController(ArticleRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public String list(@RequestParam(defaultValue = "") String q, Model model) {
        model.addAttribute("query", q);
        model.addAttribute("articles", repository.search(q));
        return "articles";               // -> templates/articles.html
    }
}

@Controller, not @RestController. The difference is the whole mechanism: @RestController adds @ResponseBody to every method, so the string "articles" would be written to the response as the body rather than resolved as a view name. Mixing the two on one class is the most common reason a browser shows the word articles on a blank page.

The template

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|Articles (${#lists.size(articles)})|">Articles</title>
    <link rel="stylesheet" th:href="@{/css/main.css}">
</head>
<body>
    <h1>Articles</h1>

    <form th:action="@{/articles}" method="get">
        <input type="search" name="q" th:value="${query}">
        <button type="submit">Search</button>
    </form>

    <p th:if="${#lists.isEmpty(articles)}">Nothing matched that search.</p>

    <ul>
        <li th:each="article : ${articles}">
            <a th:href="@{/articles/{id}(id=${article.id})}" th:text="${article.title}">Placeholder title</a>
            <span th:text="${#temporals.format(article.publishedAt, 'd MMM yyyy')}">1 Jan 2020</span>
        </li>
    </ul>
</body>
</html>

Four things there are worth naming.

@{...} is not decoration. It is the link expression, and it prefixes the application’s context path. Write href="/css/main.css" and the stylesheet 404s the moment the app is deployed under a context path such as /blog. @{/css/main.css} resolves correctly in both.

Building a URL takes the parenthesis form. @{/articles/{id}(id=${article.id})}, the path variable is declared in the template and bound in the parentheses. String-concatenating the id into the path skips URL encoding.

The literal content is a preview, not a fallback. Placeholder title inside the <a> is replaced at render time. It exists so the file opens in a browser as a static mockup, which is Thymeleaf’s natural-templating idea, and it means an unrendered template on a live page shows plausible dummy text rather than an error.

|...| is literal substitution, the readable alternative to 'Articles (' + ${n} + ')'.

th:text and th:utext

<div th:text="${article.content}">   <!-- escapes: &lt;b&gt;bold&lt;/b&gt; -->
<div th:utext="${article.content}">  <!-- raw: renders the tag -->

th:text HTML-escapes. th:utext does not, and it is a stored-XSS hole for any value that came from a user. There is a narrow legitimate use (rendering HTML your application generated itself, such as markdown you converted server-side) and even then the correct move is to sanitise the HTML before it reaches the model, not to trust the source.

If you find th:utext in a template, the question to answer is “who can write this value”, and the answer has to be “not the visitor”.

Fragments instead of copy-paste

<!-- templates/fragments/layout.html -->
<head th:fragment="head(title)">
    <meta charset="UTF-8">
    <title th:text="${title}">Site</title>
    <link rel="stylesheet" th:href="@{/css/main.css}">
</head>

<footer th:fragment="footer">
    <p>Built with Spring Boot.</p>
</footer>
<head th:replace="~{fragments/layout :: head('Articles')}"></head>
...
<footer th:insert="~{fragments/layout :: footer}"></footer>

th:replace substitutes the host tag with the fragment. th:insert keeps the host tag and puts the fragment inside it. Picking the wrong one gives you a duplicated <footer> wrapper or a missing one — a rendering oddity that reads as a CSS problem.

For a real layout, the layout:decorate mechanism from thymeleaf-layout-dialect is worth the extra dependency; fragments alone start to strain once the page has more than a header and a footer.

Iteration state and branching

th:each exposes a status variable as an optional second name, which is how you get the row index, parity and count without threading them through the model:

<tr th:each="article, stat : ${articles}"
    th:class="${stat.odd} ? 'odd' : 'even'">
    <td th:text="${stat.count}">1</td>
    <td th:text="${article.title}">Title</td>
    <td th:text="${stat.last} ? 'last row' : ''"></td>
</tr>

stat.index is 0-based and stat.count is 1-based, which is worth remembering before you write stat.index + 1 everywhere. stat.size, stat.first, stat.last, stat.even and stat.odd round out the set.

For more than two branches, th:switch avoids a stack of th:if:

<div th:switch="${article.status}">
    <span th:case="'PUBLISHED'">Live</span>
    <span th:case="'DRAFT'">Draft</span>
    <span th:case="*">Unknown</span>
</div>

th:case="*" is the default arm. Note that th:if and th:unless remove the element from the output entirely rather than hiding it, so there is no markup left behind for CSS to reveal.

Forms and binding

@GetMapping("/new")
public String form(Model model) {
    model.addAttribute("article", new ArticleForm());
    return "article-form";
}

@PostMapping
public String create(@Valid @ModelAttribute("article") ArticleForm form,
                     BindingResult result) {
    if (result.hasErrors()) {
        return "article-form";       // re-render with the submitted values
    }
    repository.save(form.toArticle());
    return "redirect:/articles";
}
<form th:action="@{/articles}" th:object="${article}" method="post">
    <input type="text" th:field="*{title}">
    <span th:if="${#fields.hasErrors('title')}" th:errors="*{title}">error</span>
    <button type="submit">Save</button>
</form>

BindingResult must be the parameter immediately after the @ModelAttribute it belongs to. Put anything between them and Spring throws before the method body runs. th:field sets id, name and value from the bound property in one attribute, and *{...} is relative to the th:object.

redirect: on success is what stops a browser refresh from re-posting the form. It issues a real 302, so the browser’s address bar and history hold the list URL rather than the POST target. To carry a one-off confirmation across that redirect, add a RedirectAttributes parameter and use addFlashAttribute, a normal model attribute does not survive it, because the model belongs to the request that is ending.

Templates that stop reloading

During development a template edit should show on refresh. Two settings decide whether it does:

spring.thymeleaf.cache=false
spring.web.resources.chain.cache=false

spring-boot-devtools on the classpath sets both for you and restarts the context on a class change.

The part that surprises people: this only works while the application runs from the IDE or mvn spring-boot:run, where the templates are read from src/main/resources. Run the packaged jar and templates are read from inside the archive, so editing the source file changes nothing. That is not a caching bug. It is a different file.

Never ship spring.thymeleaf.cache=false to production. Use a profile:

# application-dev.properties
spring.thymeleaf.cache=false

What breaks on upgrade

Thymeleaf 3.1 removed the #request, #response, #session and #servletContext expression objects. Templates that reached into the request directly: ${#request.getAttribute('x')} — stop compiling, and the fix is to put the value in the model in the controller, which is where it should have been. Spring Boot 3 also moves the servlet API to jakarta.*, so any custom dialect or WebMvcConfigurer importing javax.servlet needs updating.

For an API rather than pages, the REST controller approach drops templating entirely. More Spring Boot walkthroughs are in the Spring Boot guides.

Frequently asked questions

Where do Thymeleaf templates go?

src/main/resources/templates, resolved by the name a controller returns. Static assets go in src/main/resources/static and are served at the path below static.

Why is my page showing the view name as text?

The class is annotated @RestController, which adds @ResponseBody to every method and writes the returned string as the body. Use @Controller for views.

Why does my CSS 404 under a context path?

The link used a plain href. th:href="@{/css/main.css}" prefixes the context path; a literal /css/main.css only works when the app is deployed at the root.

What is the difference between th:text and th:utext?

th:text escapes HTML; th:utext writes it raw. Use th:utext only for HTML your own application produced and sanitised, on user input it is a stored-XSS hole.

th:replace or th:insert?

th:replace swaps out the host tag for the fragment. th:insert keeps the host tag and nests the fragment inside it.

Why do template edits not appear when I run the jar?

The packaged jar contains its own copy of the templates. Editing src/main/resources has no effect on a running jar. That is a different file, not a cache.

Do I still need spring.thymeleaf.cache=false with devtools?

No. DevTools sets it for you and restarts the context on a class change. Set it explicitly only in a dev profile if you are not using DevTools.

How do I show validation errors next to a field?

th:object on the form, th:field="*{prop}" on the input, and th:errors="*{prop}" on a neighbouring element guarded by ${#fields.hasErrors('prop')}. BindingResult must directly follow the @ModelAttribute parameter.

Can Thymeleaf render something other than HTML?

Yes, text, JavaScript, CSS and raw templates are supported modes. HTML is the default and the one Spring Boot configures.

Should I use Thymeleaf or a JavaScript front end?

Server-side templating is a good fit when the page is mostly content and the interactivity is forms and links. Once the interaction model is a long-lived client, a REST API with a separate front end is less work than fighting the template.