Many developers are comfortable building REST APIs with Spring Boot but are less sure how to build a web application that returns HTML. This article walks through server-side rendering with Thymeleaf step by step, starting from the basics and moving on to form handling, validation, and Spring Security integration.

For input validation in Spring Boot more broadly, reading How to Create Custom Validation Annotations in Spring Boot alongside this article will deepen your understanding of form validation design. If you are interested in exception handling on the REST API side, see Implementing a Production-Ready GlobalExceptionHandler in Spring Boot as well.

How Thymeleaf Relates to Spring Boot

Thymeleaf is a template engine for Java, characterized by a style where you add attributes directly to HTML files. Unlike JSP, a Thymeleaf template still renders as HTML when opened directly in a browser, which makes collaboration with designers easier.

In Spring Boot, simply adding spring-boot-starter-thymeleaf auto-configures a ThymeleafViewResolver. With Spring Boot 3.x, the Thymeleaf 3.1 line is pulled in automatically through the starter, so you do not need to align versions yourself. 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 can be confusing, but it is simple. @RestController writes values directly into the response body (for example, returning JSON). @Controller returns a template name and performs 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/        # .html テンプレートを置く場所
│   ├── index.html
│   └── fragments/
│       └── layout.html
└── static/           # CSS・JS・画像など静的ファイル
    ├── css/
    └── js/

Returning Your First HTML Response

All you need to do is populate the model in a @Controller and return the template name.

@Controller
public class HomeController {

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("message", "こんにちは、Thymeleaf!");
        model.addAttribute("items", List.of("りんご", "みかん", "ぶどう"));
        return "index"; // templates/index.html を解決する
    }
}

The template side is written like this.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>トップページ</title>
  <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
  <h1 th:text="${message}">デフォルトメッセージ</h1>

  <ul>
    <li th:each="item : ${items}" th:text="${item}">サンプル</li>
  </ul>

  <p th:if="${items.size() > 2}">3件以上あります</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 because it is not escaped, it can easily become a source of XSS vulnerabilities. Do not use it for user input or strings from external APIs; limit it to trusted, fixed text such as internationalized messages. The @{...} expression in th:href generates a URL and automatically accounts for the context path.

If you edit a template and the change does not show up in the browser, the template cache is the cause. During development, set spring.thymeleaf.cache=false in application.properties and use Spring Boot DevTools alongside it, so templates reload automatically when you save a file. In production, keep the cache enabled.

Form Objects and Data Binding

For form handling, you use a POJO known as a “command object.”

public class UserForm {
    @NotBlank(message = "名前は必須です")
    @Size(max = 50, message = "50文字以内で入力してください")
    private String name;

    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    // getter / setter
}

The GET handler passes an empty form object to the model, and the POST handler receives it.

@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 "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>名前</label>
    <input type="text" th:field="*{name}">
    <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" style="color:red"></span>
  </div>
  <div>
    <label>メール</label>
    <input type="email" th:field="*{email}">
    <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" style="color:red"></span>
  </div>
  <button type="submit">登録</button>
</form>

Writing th:field="*{name}" automatically generates id="name" name="name" value="...". th:errors displays all error messages associated with that field.

If you forget model.addAttribute("userForm", new UserForm()) in the GET handler, th:object will be null during template rendering and an error will occur. This is a common pitfall for beginners.

Splitting Templates into Fragments

Repeating the header and footer on every page is painful, so extract shared parts with th:fragment.

<!-- fragments/layout.html -->
<header th:fragment="header">
  <nav>
    <a th:href="@{/}">ホーム</a>
    <a th:href="@{/users/new}">新規登録</a>
  </nav>
</header>

Each page template embeds it like this.

<body>
  <div th:replace="~{fragments/layout :: header}"></div>
  <!-- ページ固有のコンテンツ -->
</body>

th:replace replaces the target element entirely, while th:insert inserts the fragment inside the target element. A simple rule of thumb is enough: use th:replace for self-contained parts like headers and footers, and th:insert only when you want to keep the wrapper element.

Integrating with Spring Security

If you are using Spring Security, adding thymeleaf-extras-springsecurity6 lets you use custom attributes such as sec:authorize. The trailing 6 means it targets the Spring Security 6 line. Be careful not to confuse it with thymeleaf-extras-springsecurity5, which is for the 5 line, as that will cause a build error.

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>ようこそ、<span sec:authentication="name"></span> さん</p>
    <a th:href="@{/logout}">ログアウト</a>
  </div>
  <div sec:authorize="!isAuthenticated()">
    <a th:href="@{/login}">ログイン</a>
  </div>
</body>
</html>

Spring Security’s SpEL expressions can be used directly inside sec:authorize. For configuring Spring Security itself, see Spring Boot Security Basic Authentication Tutorial.

Note that Spring Security automatically inserts a CSRF token into forms by default. When combined with Thymeleaf, <input type="hidden" name="_csrf" ...> is added automatically, so you do not need to do anything special.

Summary

Thymeleaf works extremely well with Spring Boot, and it is a pleasure that it starts working just by adding the starter.

  • Return a template name from a @Controller to produce an HTML response
  • Build dynamic HTML with th:text, th:each, and th:if
  • Bind forms to objects with th:object and th:field
  • Display validation errors in templates with th:errors
  • Share layouts with th:fragment and th:replace
  • Switch what is displayed based on authentication state with thymeleaf-extras-springsecurity6

It is a different approach from REST APIs, but for situations where you want to generate HTML on the backend, such as admin screens and internal tools, it remains a perfectly practical choice today. Give it a try.