Many developers feel comfortable building REST APIs with Spring Boot but aren’t sure how to create web applications that return HTML. This article is for those developers — walking through the basics of server-side rendering with Thymeleaf, all the way to form handling, validation, and Spring Security integration.
The Relationship Between Thymeleaf and Spring Boot
Thymeleaf is a template engine for Java characterized by its approach of adding attributes directly to HTML files. Unlike JSP, Thymeleaf files render as valid HTML when opened directly in a browser, which makes collaboration with designers much easier.
In Spring Boot, simply adding spring-boot-starter-thymeleaf automatically configures ThymeleafViewResolver. When a @Controller handler returns a template name (a string), the corresponding .html file under src/main/resources/templates/ is resolved automatically.
The difference from @RestController is straightforward. @RestController writes values directly to the response body (e.g., returning JSON). @Controller returns a template name and delegates to view resolution.
Adding Dependencies
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
The directory structure looks like this:
src/main/resources/
├── templates/ # Place .html templates here
│ ├── index.html
│ └── fragments/
│ └── layout.html
└── static/ # Static files: CSS, JS, images, etc.
├── css/
└── js/
Returning Your First HTML Response
All you need to do is populate the model with data in a @Controller and return a template name.
@Controller
public class HomeController {
@GetMapping("/")
public String index(Model model) {
model.addAttribute("message", "Hello, Thymeleaf!");
model.addAttribute("items", List.of("Apple", "Orange", "Grape"));
return "index"; // resolves templates/index.html
}
}
The template is written like this:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Home</title>
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<h1 th:text="${message}">Default Message</h1>
<ul>
<li th:each="item : ${items}" th:text="${item}">Sample</li>
</ul>
<p th:if="${items.size() > 2}">3 or more items</p>
</body>
</html>
th:text outputs the value with HTML escaping applied. If you want to output a string containing HTML tags as-is, use th:utext — but be careful about XSS. The @{...} expression in th:href generates URLs and automatically accounts for the context path.
Form Objects and Data Binding
Form handling uses POJOs called “command objects.”
public class UserForm {
@NotBlank(message = "Name is required")
@Size(max = 50, message = "Must be 50 characters or fewer")
private String name;
@Email(message = "Invalid email address format")
private String email;
// getters / setters
}
The GET handler passes an empty form object to the model, and the POST handler receives the submitted one.
@Controller
@RequestMapping("/users")
public class UserController {
@GetMapping("/new")
public String newUser(Model model) {
model.addAttribute("userForm", new UserForm());
return "users/new";
}
@PostMapping
public String create(@Valid @ModelAttribute UserForm userForm,
BindingResult result) {
if (result.hasErrors()) {
return "users/new"; // return to the form on validation error
}
// save logic...
return "redirect:/users";
}
}
For details on validation annotations, see Validation with @Valid in Spring Boot.
In the template, use th:object and th:field:
<form th:action="@{/users}" th:object="${userForm}" method="post">
<div>
<label>Name</label>
<input type="text" th:field="*{name}">
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" style="color:red"></span>
</div>
<div>
<label>Email</label>
<input type="email" th:field="*{email}">
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" style="color:red"></span>
</div>
<button type="submit">Register</button>
</form>
Writing th:field="*{name}" automatically generates id="name" name="name" value="...". th:errors displays all error messages associated with that field.
Forgetting model.addAttribute("userForm", new UserForm()) in the GET handler will cause th:object to be null during template rendering, resulting in an error. This is a common pitfall for beginners.
Template Fragments
Repeating headers and footers across every page is tedious, so use th:fragment to extract shared components.
<!-- fragments/layout.html -->
<header th:fragment="header">
<nav>
<a th:href="@{/}">Home</a>
<a th:href="@{/users/new}">New Registration</a>
</nav>
</header>
Embed fragments in page templates like this:
<body>
<div th:replace="~{fragments/layout :: header}"></div>
<!-- Page-specific content -->
</body>
th:replace replaces the target element entirely, while th:insert inserts content inside the target element. For standalone components like headers and footers, th:replace is the more natural choice.
Integration with Spring Security
When using Spring Security, adding thymeleaf-extras-springsecurity6 enables custom attributes like sec:authorize.
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
<div sec:authorize="isAuthenticated()">
<p>Welcome, <span sec:authentication="name"></span></p>
<a th:href="@{/logout}">Logout</a>
</div>
<div sec:authorize="!isAuthenticated()">
<a th:href="@{/login}">Login</a>
</div>
</body>
</html>
Spring Security SpEL expressions can be used directly inside sec:authorize. For Spring Security configuration itself, see Spring Boot Security Basic Authentication Tutorial.
Note that Spring Security automatically injects a CSRF token into forms by default. When combined with Thymeleaf, <input type="hidden" name="_csrf" ...> is added automatically, so you don’t need to handle it manually.
Summary
Thymeleaf integrates exceptionally well with Spring Boot — it’s great that it works right out of the box just by adding the starter.
- Return a template name from
@Controllerto produce an HTML response - Build dynamic HTML with
th:text,th:each, andth:if - Bind forms to objects using
th:objectandth:field - Display validation errors in templates with
th:errors - Share layouts across pages using
th:fragmentandth:replace - Use
thymeleaf-extras-springsecurity6to conditionally render content based on authentication state
It’s a different approach from REST APIs, but for use cases where you need to generate HTML on the backend — such as admin dashboards or internal tools — it remains a thoroughly practical choice. Give it a try.