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 @Controller to produce an HTML response
  • Build dynamic HTML with th:text, th:each, and th:if
  • Bind forms to objects using th:object and th:field
  • Display validation errors in templates with th:errors
  • Share layouts across pages using th:fragment and th:replace
  • Use thymeleaf-extras-springsecurity6 to 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.