HomeSpring BootThymeleaf Integration
Spring Boot

Thymeleaf Integration

Thymeleaf is the recommended server-side template engine for Spring Boot. It processes HTML templates on the server, binding model data to the markup using th:* attributes. Templates are valid HTML files that open correctly in a browser without a running server — a property Thymeleaf calls Natural Templating. Spring Boot auto-configures everything with a single starter dependency.

How Thymeleaf Works with Spring Boot

Thymeleaf is a server-side template engine. A controller returns a view name, Spring's ThymeleafViewResolver finds the matching HTML file under src/main/resources/templates, and the Thymeleaf engine processes it: attributes that start with th: are evaluated against the model and fill in or replace the static markup. The result is plain HTML sent to the browser. Two properties make Thymeleaf pleasant to use with Spring Boot. First, templates are valid HTML. A designer can open a template directly in a browser and see the placeholder text, because browsers ignore the th: attributes. Thymeleaf calls this Natural Templating. Second, output is escaped by default: th:text converts characters such as < and & into HTML entities, so user input cannot inject markup unless you deliberately use th:utext. Compared with JSP, Thymeleaf needs no special packaging, works from an executable JAR, and is auto-configured by a single starter dependency. That is why it is the recommended choice for new Spring Boot applications with server-rendered pages.

Setup and Auto-Configuration

Adding spring-boot-starter-thymeleaf to the classpath triggers ThymeleafAutoConfiguration. It registers a SpringResourceTemplateResolver that loads templates from classpath:/templates/ with the .html suffix, a SpringTemplateEngine, and a ThymeleafViewResolver. No Java configuration is required: put the template files in place and start the application. The application.yml below lists the properties you are most likely to change. The values shown for prefix, suffix, mode, encoding and the two check settings are the defaults, so you only need them if you want something different. Template caching is on by default, which is right for production. During development you can set cache to false so that edited templates are picked up without a restart; Spring Boot DevTools does this for you automatically.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

# ── application.yml — prefix, suffix, mode, encoding and the check-* ──
# ── settings below are the defaults; override only when needed: ───────
spring:
  thymeleaf:
    prefix: classpath:/templates/   # template root
    suffix: .html                   # file extension
    mode: HTML                      # template mode: HTML, XML, TEXT, JAVASCRIPT, CSS
    encoding: UTF-8
    cache: false                    # default is true; use false only in development
    check-template-location: true   # fail fast if templates/ directory missing
    check-template: true            # fail fast if a specific template is missing
    servlet:
      content-type: text/html       # response Content-Type

# ── Template file layout: ─────────────────────────────────────────────
# src/main/resources/
# └── templates/
#     ├── index.html
#     ├── layout/
#     │   └── base.html            ← shared layout fragment
#     ├── users/
#     │   ├── list.html            ← resolved by "users/list"
#     │   ├── detail.html          ← resolved by "users/detail"
#     │   └── form.html            ← resolved by "users/form"
#     └── error/
#         ├── 404.html
#         └── 500.html

A Controller for a Thymeleaf Page

A Thymeleaf page is a controller method plus a template. The controller adds data to the Model and returns a view name. With the default configuration, the view name users/list is resolved to src/main/resources/templates/users/list.html. The controller must be a @Controller, not a @RestController, because a @RestController would write the returned String straight into the response instead of rendering a template. The sections below show what goes inside the template.
Java
@Controller
@RequestMapping("/users")
public class UserPageController {

    private final UserService userService;

    public UserPageController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public String list(Model model) {
        model.addAttribute("pageTitle", "All Users");
        model.addAttribute("users", userService.findAll());
        return "users/list";   // -> src/main/resources/templates/users/list.html
    }
}

Core Thymeleaf Expressions

Thymeleaf has five kinds of expressions, each with its own delimiter. Variable expressions ${...} read model attributes and call methods on them. Selection expressions *{...} read properties of the object chosen with th:object, which keeps form templates short. Message expressions #{...} look up text in message bundles, which is how you internationalize a page. Link expressions @{...} build URLs and add the application's context path, and they accept path variables and query parameters. Fragment expressions ~{...} point to reusable pieces of a template. Inside these expressions you can use string literals, the |...| literal-substitution shortcut, arithmetic, comparisons, the ternary operator, the Elvis operator (?:) for default values, and the safe-navigation operator (?.) to avoid errors on null values. A note on escaping: th:text escapes HTML, so user input is displayed safely. th:utext does not escape, so use it only for HTML you trust. Static files placed in src/main/resources/static are served from the root path, so link to them as @{/css/main.css} or @{/images/logo.png}, without a /static prefix.
html
<!-- ── Variable expression ${...} — access model attributes: ───────── -->
<p th:text="${user.name}">Placeholder name</p>
<p th:text="${user.role.name()}">ROLE</p>

<!-- ── Selection expression *{...} — access fields on th:object: ────── -->
<form th:object="${userForm}">
    <input th:field="*{name}" />       <!-- = th:field="${userForm.name}" -->
    <input th:field="*{email}" />
</form>

<!-- ── Message expression #{...} — internationalisation keys: ────────── -->
<h1 th:text="#{page.users.title}">Users</h1>
<p th:text="#{validation.required}">This field is required</p>

<!-- ── Link expression @{...} — URL building: ────────────────────────── -->
<a th:href="@{/users}">All users</a>
<a th:href="@{/users/{id}(id=${user.id})}">View user</a>
<a th:href="@{/users(page=${page},size=20)}">Page</a>  <!-- query params -->
<img th:src="@{/images/logo.png}" />   <!-- file: static/images/logo.png -->

<!-- ── Fragment expression ~{...} — include fragments: ──────────────── -->
<div th:insert="~{layout/base :: header}"></div>
<div th:replace="~{layout/base :: footer}"></div>

<!-- ── Literals and operations inside expressions: ───────────────────── -->
<p th:text="'Hello, ' + ${user.name} + '!'"></p>
<p th:text="|Hello, ${user.name}!|"></p>          <!-- literal substitution -->
<p th:text="${user.age >= 18 ? 'Adult' : 'Minor'}"></p>   <!-- ternary -->
<p th:text="${user.bio} ?: 'No bio provided'"></p>        <!-- Elvis -->
<p th:text="${user.name?.toUpperCase()}"></p>            <!-- safe navigation -->

<!-- ── th:text escapes HTML; th:utext does not (trusted HTML only): ──── -->
<p th:text="${comment}">Escaped: &lt;b&gt; is shown as text</p>
<div th:utext="${trustedHtml}">Unescaped: markup is rendered</div>

Iteration, Conditionals, and Attributes

th:each repeats an element for every item in a collection, and an optional second variable gives you iteration status: index (0-based), count (1-based), size, first, last, odd and even. th:if renders an element only when its condition is true, and th:unless is the opposite. When a condition fails, the element and all its children are removed from the output, not merely hidden. th:switch and th:case handle multi-way branching, and th:classappend adds a CSS class conditionally without replacing the existing ones. th:attr sets any HTML attribute, and th:attrappend adds to an existing one. Use the dedicated attributes such as th:href, th:value and th:src where they exist, since they are shorter. Finally, th:remove strips elements from the final output. It is useful for sample rows that make the static template look realistic in a browser but should never be sent to real users.
html
<!-- ── th:each — iterate over a collection: ─────────────────────────── -->
<tr th:each="user : ${users}">
    <td th:text="${user.name}"></td>
    <td th:text="${user.email}"></td>
</tr>

<!-- th:each provides an iteration status variable: -->
<tr th:each="user, stat : ${users}"
    th:classappend="${stat.odd} ? 'odd' : 'even'">
    <td th:text="${stat.index}"></td>    <!-- 0-based index -->
    <td th:text="${stat.count}"></td>    <!-- 1-based count -->
    <td th:text="${stat.size}"></td>     <!-- total size -->
    <td th:text="${stat.first}"></td>    <!-- true on first iteration -->
    <td th:text="${stat.last}"></td>     <!-- true on last iteration -->
    <td th:text="${user.name}"></td>
</tr>

<!-- ── th:if / th:unless — conditional rendering: ───────────────────── -->
<p th:if="${users.empty}">No users found.</p>
<p th:unless="${users.empty}" th:text="${users.size()} + ' users found'"></p>

<!-- th:if on a container removes both the element and its children: -->
<div th:if="${currentUser.admin}">
    <a href="/admin">Admin Panel</a>
</div>

<!-- ── th:switch / th:case: ──────────────────────────────────────────── -->
<span th:switch="${user.role}">
    <span th:case="'ADMIN'" class="badge-red">Admin</span>
    <span th:case="'USER'"  class="badge-blue">User</span>
    <span th:case="*"       class="badge-gray">Unknown</span>
</span>

<!-- ── th:classappend — add CSS class conditionally: ────────────────── -->
<tr th:classappend="${user.active} ? 'active' : 'inactive'">

<!-- ── th:attr — set any HTML attribute: ────────────────────────────── -->
<input th:attr="placeholder=#{form.name.placeholder},
                maxlength=${maxLength}" />

<!-- ── th:attrappend / th:attrprepend: ──────────────────────────────── -->
<input class="form-control" th:attrappend="class=${' is-invalid'}"/>

<!-- ── th:remove — remove elements from the final output: ───────────── -->
<!-- Sample row: visible when the template is opened as a static file,
     removed when Thymeleaf processes it: -->
<tr th:remove="all">
    <td>Sample data</td>
</tr>

Fragments and Layouts

Fragments are reusable pieces of a template. th:fragment names one, and another template includes it with th:insert or th:replace. th:insert keeps the host element and places the fragment inside it, while th:replace substitutes the host element with the fragment itself. Most projects use th:replace for headers, navigation and footers, because it does not leave an extra wrapper element in the HTML. Fragments can take parameters, as userCard(user) does below, which turns them into small reusable components. For page-level layouts with a shared skeleton and content slots, the separate Thymeleaf Layout Dialect adds a layout inheritance system. For many applications plain fragments are enough.
html
<!-- ── Define fragments in a shared file (templates/layout/base.html): ─ -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:fragment="head(title)">
    <meta charset="UTF-8" />
    <title th:text="${title} + ' | MyApp'">MyApp</title>
    <link rel="stylesheet" th:href="@{/css/main.css}" />
</head>

<nav th:fragment="navbar">
    <a th:href="@{/}">Home</a>
    <a th:href="@{/users}">Users</a>
</nav>

<footer th:fragment="footer">
    <p>&copy; MyApp</p>
</footer>

</html>

<!-- ── Include fragments in a page template: ────────────────────────── -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:replace="~{layout/base :: head('Users')}"></head>

<body>
    <!-- th:insert — keeps the container element, inserts fragment inside: -->
    <div th:insert="~{layout/base :: navbar}"></div>

    <!-- th:replace — replaces the container element with the fragment: -->
    <nav th:replace="~{layout/base :: navbar}"></nav>

    <main>
        <h1>Users</h1>
        <!-- page-specific content -->
    </main>

    <th:block th:replace="~{layout/base :: footer}"></th:block>
</body>
</html>

<!-- ── Fragment with parameters: ────────────────────────────────────── -->
<!-- Define: -->
<div th:fragment="userCard(user)">
    <h3 th:text="${user.name}"></h3>
    <p th:text="${user.email}"></p>
</div>

<!-- Use: -->
<div th:replace="~{fragments/user :: userCard(${selectedUser})}"></div>

<!-- ── Inline fragment (same file): ─────────────────────────────────── -->
<div th:replace="~{:: #my-fragment}"></div>
<div id="my-fragment">content</div>

Utility Objects

Thymeleaf provides utility objects, written with a # prefix, for common formatting and helper tasks. #temporals formats java.time values, #dates handles the older java.util.Date, #numbers formats numbers and currency, #strings offers string helpers, and #lists and #sets work with collections. #fields is used in forms to check validation errors. Be careful with the web-related objects. Older Thymeleaf versions exposed #request, #session and #servletContext, but Thymeleaf 3.1, which Spring Boot 3 uses, removed them for security reasons. Read request parameters with ${param.q} and session attributes with ${session.userId} instead. Inline expressions such as [[${user.name}]] let you place values directly in text. In a script block with th:inline="javascript", Thymeleaf writes the value as a valid JavaScript literal, which is the safe way to pass model data to client-side code.
html
<!-- ── #dates / #temporals — date and time formatting: ──────────────── -->
<p th:text="${#temporals.format(user.createdAt, 'dd MMM yyyy')}"></p>
<p th:text="${#temporals.format(user.createdAt, 'dd/MM/yyyy HH:mm')}"></p>
<p th:text="${#dates.format(legacyDate, 'yyyy-MM-dd')}"></p>

<!-- ── #numbers — number formatting: ────────────────────────────────── -->
<p th:text="${#numbers.formatDecimal(price, 1, 2)}"></p>      <!-- e.g. 1234.56 -->
<p th:text="${#numbers.formatDecimal(price, 1, 'COMMA', 2, 'POINT')}"></p>  <!-- 1,234.56 -->
<p th:text="${#numbers.formatCurrency(amount)}"></p>   <!-- uses the current locale -->

<!-- ── #strings — string utilities: ─────────────────────────────────── -->
<p th:text="${#strings.toUpperCase(user.name)}"></p>
<p th:text="${#strings.abbreviate(user.bio, 100)}"></p>
<p th:if="${#strings.isEmpty(user.bio)}">No bio</p>
<p th:text="${#strings.replace(text, 'old', 'new')}"></p>

<!-- ── #lists / #sets — collection utilities: ───────────────────────── -->
<p th:text="${#lists.size(users)}"></p>
<p th:if="${#lists.isEmpty(users)}">Empty</p>

<!-- ── #fields — form validation (see Form Validation entry): ────────── -->
<span th:if="${#fields.hasErrors('email')}"
      th:errors="*{email}"></span>

<!-- ── Request parameters and session attributes (Thymeleaf 3.1+): ───── -->
<!-- #request, #session and #servletContext were removed in 3.1. -->
<p th:text="${param.q}"></p>            <!-- ?q=... request parameter -->
<p th:text="${session.userId}"></p>     <!-- session attribute -->

<!-- ── Inline expressions — use Thymeleaf inside text nodes: ─────────── -->
<p>Hello, [[${user.name}]]!</p>               <!-- escaped output -->
<script th:inline="javascript">
    const userId = [[${user.id}]];            <!-- JS-safe output -->
    const userName = [[${user.name}]];
</script>

Spring Security Integration

The Thymeleaf Extras Spring Security library adds sec:* attributes that show or hide content based on authentication state and roles, without extra controller logic. For Spring Boot 3 and Spring Security 6, use the thymeleaf-extras-springsecurity6 artifact; Spring Boot manages its version. sec:authorize takes a Spring Security expression such as isAuthenticated() or hasRole('ADMIN'), and sec:authentication prints details of the current user. Hiding a link in the template is only a display convenience. It does not protect the URL, so the same rule must also be enforced in your security configuration or with method-level security. When Spring Security is present, forms that use th:action get the hidden CSRF token added automatically.
XML
<!-- pom.xml: -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>

<!-- Template — add the sec namespace: -->
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<!-- ── Show only when authenticated: ────────────────────────────────── -->
<div sec:authorize="isAuthenticated()">
    <p>Welcome back!</p>
</div>

<!-- ── Show only when NOT authenticated: ────────────────────────────── -->
<div sec:authorize="!isAuthenticated()">
    <a th:href="@{/login}">Login</a>
    <a th:href="@{/register}">Register</a>
</div>

<!-- ── Show only for a specific role: ───────────────────────────────── -->
<div sec:authorize="hasRole('ADMIN')">
    <a th:href="@{/admin}">Admin Panel</a>
</div>

<!-- ── Show for any of multiple roles: ──────────────────────────────── -->
<div sec:authorize="hasAnyRole('ADMIN', 'MODERATOR')">
    <a th:href="@{/moderate}">Moderate</a>
</div>

<!-- ── Display the authenticated user's details: ─────────────────────── -->
<span sec:authentication="name">Username</span>
<span sec:authentication="principal.email">email</span>

<!-- ── CSRF token in forms (auto-included by Spring Security, but explicit): -->
<form method="post" th:action="@{/users}">
    <input type="hidden" th:name="${_csrf.parameterName}"
                         th:value="${_csrf.token}" />
    <!-- fields -->
</form>
<!-- Spring Security's Thymeleaf integration adds CSRF automatically
     to any th:action form — the explicit input above is not needed. -->

Testing Thymeleaf Controllers

Thymeleaf works well with MockMvc because @WebMvcTest includes the Thymeleaf auto-configuration, so the template is actually rendered into the response. You can assert the view name and the model as usual, and also check that the rendered HTML contains the text you expect. This catches template mistakes, such as a wrong attribute name, that a view-name check alone would miss. The test below assumes list.html shows the pageTitle attribute.
Java
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

@WebMvcTest(UserPageController.class)
class UserPageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean   // use @MockBean on Spring Boot versions before 3.4
    private UserService userService;

    @Test
    void listRendersUsersPage() throws Exception {
        mockMvc.perform(get("/users"))
            .andExpect(status().isOk())
            .andExpect(view().name("users/list"))
            .andExpect(model().attributeExists("users"))
            .andExpect(content().string(containsString("All Users")));
    }
}

Common Mistakes with Thymeleaf

- Putting templates in the wrong place. Thymeleaf templates must be under src/main/resources/templates, and the view name must match the path without the .html suffix. Otherwise you get an error such as Error resolving template. - Using @RestController for a page. It writes the returned view name as plain text instead of rendering the template. Use @Controller. - Reading a property on a missing object. If user is null, ${user.name} fails. Add the attribute in the controller, or use ${user?.name}. - Using th:utext with user input. It skips HTML escaping and opens the page to cross-site scripting. Use th:text. - Using #request or #session on Spring Boot 3. Those objects were removed in Thymeleaf 3.1. Use ${param.name} and ${session.name}. - Confusing th:insert and th:replace. th:insert keeps the host element and puts the fragment inside it, which often leaves an unwanted extra wrapper. th:replace substitutes the host element. - Linking static files with a /static prefix. Files in src/main/resources/static are served from the root, so the URL is /css/main.css, not /static/css/main.css. - Leaving spring.thymeleaf.cache set to false in production. Template caching is on by default and should stay on.