Skip to main content

Thymeleaf Tags, Expressions, and Spring MVC Controllers

The true power of Thymeleaf lies in combining a standard dialect of HTML attributes (th:*) with an expressive expression language. Together, these mechanisms seamlessly bind data injected by Spring Boot controllers to visual elements in the web browser.


1. Theoretical Foundations: Expressions and Control Flow​

Thymeleaf defines five core expression syntaxes, each designed for a specific purpose within the rendering lifecycle:

The 5 Types of Expressions in Thymeleaf​

SyntaxNamePrimary PurposePractical Example
${...}Variable ExpressionAccesses attributes in the Model, session variables, or request parameters.<span th:text="${user.name}"></span>
*{...}Selection ExpressionAccesses properties relative to an object previously defined via th:object.<input th:field="*{email}" />
#{...}Message ExpressionRetrieves internationalized (i18n) messages from messages.properties files.<button th:text="#{btn.submit}"></button>
@{...}Link ExpressionBuilds context-aware absolute or relative URLs, honoring application deployment paths.<a th:href="@{/users/{id}(id=${u.id})}"></a>
~{...}Fragment ExpressionReferences and inserts modular template fragments defined in external templates.<div th:replace="~{components/nav :: bar}"></div>

The Post/Redirect/Get (PRG) Pattern​

A common anti-pattern when building web applications with Spring MVC and template engines is directly returning a view template after handling a POST submission (for example, saving a user and returning "users/list"). If the user presses F5 or the browser "Reload" button, the browser will repeat the POST request, inadvertently creating duplicate database records.

To prevent this issue, the Post/Redirect/Get (PRG) design pattern is implemented:

Clic para ampliar

Technical Explanation of the PRG Pattern​

  1. POST Reception: The method annotated with @PostMapping receives the form payload, validates inputs, and persists the entity in the database.
  2. HTTP 302 Redirect: Instead of returning an HTML template name directly, the controller returns the instruction return "redirect:/mvc/users";. This instructs the browser to issue a fresh HTTP GET request.
  3. Idempotent GET Request: The browser requests the specified URL via GET /mvc/users. If the user refreshes the page multiple times, only the read-only GET query is executed again, guaranteeing that record insertion is never accidentally duplicated.

2. Thymeleaf Tag Catalog (th:*)​

Below are the most widely used attributes of the standard dialect, along with their practical use cases and security considerations.

1. Text Insertion and XSS Prevention (th:text vs th:utext)​

src/main/resources/templates/examples/text.html
<!-- th:text automatically escapes HTML special characters (Safe against XSS attacks) -->
<!-- If ${user.bio} contains "<script>alert('hack')</script>", it renders as harmless escaped text -->
<p th:text="${user.bio}">Default preview biography</p>

<!-- th:utext (Unescaped Text) renders raw unescaped HTML markup (USE WITH CAUTION) -->
<!-- Only use with trusted, sanitized content from the backend -->
<div th:utext="${article.htmlContent}">Formatted content containing bold tags or links</div>

2. Conditional Structures (th:if, th:unless, th:switch)​

Thymeleaf evaluates boolean conditions and object nullability with precision:

src/main/resources/templates/examples/conditionals.html
<!-- th:if: The element renders ONLY if the expression evaluates to true or non-null -->
<div th:if="${successMessage != null}" class="alert alert-success">
<span th:text="${successMessage}">Operation completed successfully</span>
</div>

<!-- th:unless: Evaluates conversely (renders ONLY if the condition is FALSE or null) -->
<div th:unless="${user.active}" class="alert alert-warning">
<span>This account is temporarily suspended.</span>
</div>

<!-- th:switch and th:case: Multi-branch condition (ideal for roles or status badges) -->
<div th:switch="${user.role.name}">
<!-- Rendered if role equals 'ADMIN' -->
<span th:case="'ADMIN'" class="badge badge-danger">Administrator</span>

<!-- Rendered if role equals 'MODERATOR' -->
<span th:case="'MODERATOR'" class="badge badge-warning">Moderator</span>

<!-- Default fallback case if no prior condition matched (*) -->
<span th:case="*" class="badge badge-secondary">Standard User</span>
</div>

3. Collection Iteration (th:each) and the Iteration Status Variable​

When iterating over lists or sets with th:each, Thymeleaf provides a helpful status variable that exposes useful loop metadata (index, count, size, even/odd flags):

src/main/resources/templates/examples/tables.html
<table>
<thead>
<tr>
<th>#</th>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Even Row</th>
</tr>
</thead>
<tbody>
<!-- 'stat' is the iteration status variable -->
<!-- Available properties: index (0-indexed), count (1-indexed), size, current, even, odd, first, last -->
<tr th:each="user, stat : ${users}" th:classappend="${stat.odd} ? 'row-odd' : 'row-even'">
<!-- stat.count provides human-readable row numbers (1, 2, 3...) -->
<td th:text="${stat.count}">1</td>
<td th:text="${user.id}">101</td>
<td th:text="${user.username}">jperez</td>
<td th:text="${user.email}">jperez@icesi.edu.co</td>
<td th:text="${stat.even ? 'Yes' : 'No'}">No</td>
</tr>
</tbody>
</table>

The @{...} syntax guarantees that URLs are resolved correctly regardless of whether the app is deployed at the server root or under a servlet context path (such as /store):

src/main/resources/templates/examples/links.html
<!-- 1. Link to static asset hosted in /static/css/ -->
<link rel="stylesheet" th:href="@{/css/users/list.css}" />

<!-- 2. Dynamic route with Path Variables -->
<!-- Resolves to: /mvc/users/42/details -->
<a th:href="@{/mvc/users/{id}/details(id=${user.id})}">View Details</a>

<!-- 3. Dynamic route with Query Parameters -->
<!-- Resolves to: /mvc/users/edit?id=42&action=update -->
<a th:href="@{/mvc/users/edit(id=${user.id}, action='update')}">Edit</a>

5. Form Binding and Bidirectional Mapping (th:object, th:field, th:action)​

Form binding automatically connects Java model properties to HTML form input elements:

src/main/resources/templates/users/form-example.html
<!-- th:action: Form submission endpoint with link expression -->
<!-- th:object: Binds the entire form to the 'newUser' model attribute -->
<form th:action="@{/mvc/users/save}" th:object="${newUser}" method="post">

<div>
<label for="username">Username:</label>
<!-- th:field="*{username}" automatically generates id="username", name="username", and value="..." -->
<input type="text" id="username" th:field="*{username}" required />
</div>

<div>
<label for="email">Email Address:</label>
<!-- Selection expression: equivalent to ${newUser.email} -->
<input type="email" id="email" th:field="*{email}" required />
</div>

<div>
<label for="role">Assigned Role:</label>
<!-- Binds selected role id to the role.id property of the model object -->
<select id="role" th:field="*{role.id}">
<option th:each="r : ${roles}" th:value="${r.id}" th:text="${r.name}">Administrator</option>
</select>
</div>

<button type="submit">Register User</button>
</form>
What does th:field do automatically?

When using th:field="*{property}":

  1. Generates the name="property" attribute for HTTP POST request binding.
  2. Generates the id="property" attribute for accessibility pairing with <label for="...">.
  3. Injects the current property value into value="value" (crucial in pre-populated edit forms).

3. Data Manipulation in Spring MVC Controllers​

Controllers annotated with @Controller serve as the bridge between HTTP requests and HTML templates.

src/main/java/com/icesi/store/controller/UserController.java
package com.icesi.store.controller;

import com.icesi.store.model.User;
import com.icesi.store.service.IUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
@RequestMapping("/mvc/users")
@RequiredArgsConstructor
public class UserController {

private final IUserService userService;

// 1. @GetMapping: Handles read requests and injects data into the Model
@GetMapping
public String listUsers(Model model) {
List<User> userList = userService.findAll();
// Injects the list into the Thymeleaf context under the key "users"
model.addAttribute("users", userList);
// Returns the physical template path templates/users/list.html
return "users/list";
}

// 2. @PathVariable: Extracts variables encoded directly in URL path segments
// Example: GET /mvc/users/42/details
@GetMapping("/{id}/details")
public String viewDetails(@PathVariable("id") Long id, Model model) {
User user = userService.findById(id);
model.addAttribute("user", user);
return "users/details";
}

// 3. @RequestParam: Captures standard query parameters from query strings
// Example: GET /mvc/users/filter?status=active
@GetMapping("/filter")
public String filterByStatus(@RequestParam(value = "status", defaultValue = "all") String status, Model model) {
model.addAttribute("users", userService.findByStatus(status));
return "users/list";
}

// 4. @ModelAttribute: Automatically deserializes submitted form fields into a Java object
@PostMapping("/save")
public String saveUser(@ModelAttribute("newUser") User user) {
userService.save(user);
// Applies Post/Redirect/Get pattern to prevent duplicate submissions on refresh
return "redirect:/mvc/users";
}
}

4. Self-Assessment Quiz​

Cargando cuestionario...