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

For general input validation in Spring Boot, reading How to Create Custom Validation Annotations in Spring Boot alongside this article will deepen your understanding of form input check design. If you’re interested in exception handling on the REST API side, see Implementing Spring Boot’s GlobalExceptionHandler for Production as well.

The Relationship Between Thymeleaf and Spring Boot

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

In Spring Boot, simply adding spring-boot-starter-thymeleaf auto-configures ThymeleafViewResolver. When a @Controller handler returns a template name (as a string), the corresponding .html file under src/main/resources/templates/ is automatically resolved.

The distinction from @RestController can be confusing, but it’s simple. @RestController writes values directly to the response body (returning JSON, etc.). @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/        # Place .html templates here
│   ├── index.html
│   └── fragments/
│       └── layout.html
└── static/           # Static files like CSS, JS, images
    ├── css/
    └── js/

Returning Your First HTML Response

In @Controller, just pack data into the model 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 values with HTML escaping. If you want to output a string containing HTML tags as-is, use th:utext, but be aware of XSS risks. The @{...} syntax in th:href is an expression that 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 = "名前は必須です")
    @Size(max = 50, message = "50文字以内で入力してください")
    private String name;

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

    // getter / setter
}

In the GET handler, pass an empty form object to the model, and receive it in the POST handler.

@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 Using @Valid in Spring Boot.

In templates, 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 bound to that field.

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

Template Fragmentation

Repeating headers and footers across every page is painful, so let’s extract common parts using th:fragment.

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

Embed it from each page template like this:

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

th:replace replaces the target element itself, while th:insert inserts content inside the target element. For independent parts like headers and footers, th:replace is more natural.

Integration with Spring Security

Frequently Asked Questions

Q. I get an error because th:object is null

In most cases, this is because you forgot to register an empty form object in the model in the GET handler, like model.addAttribute("userForm", new UserForm()). If the model attribute doesn’t exist when th:object="${userForm}" is evaluated, a NullPointerException occurs during template rendering.

Q. How should I choose between th:text and th:utext?

th:text HTML-escapes values before outputting them, making it safe against XSS. th:utext outputs without escaping. Using th:utext with user input or strings from external APIs becomes a hotbed for XSS, so don’t use it for anything other than trusted fixed text (such as i18n messages).

Q. Template edits aren’t reflected in the browser

In production builds, template caching is enabled. During development, set spring.thymeleaf.cache=false in application.properties and combine it with Spring Boot DevTools for comfortable auto-reload on file save. In production environments, keep caching enabled.

Q. What’s the Spring Boot 3.x and Thymeleaf version compatibility?

Spring Boot 3.x automatically pulls in Thymeleaf 3.1.x via spring-boot-starter-thymeleaf. When combining with Spring Security, choose thymeleaf-extras-springsecurity6 (the trailing 6 indicates Spring Security 6.x compatibility). thymeleaf-extras-springsecurity5 is for Spring Security 5.x, so getting the version wrong results in a build error.

Q. What’s the difference between th:replace and th:insert?

th:replace replaces the target element itself with the fragment. th:insert inserts the fragment inside the target element. The basic pattern is to use th:replace for independent parts like headers and footers, and th:insert when you want to keep a wrapper element.

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

Inside sec:authorize, you can use Spring Security SpEL expressions directly. For Spring Security configuration itself, see Spring Boot Security Basic Authentication Tutorial.

Note that Spring Security automatically inserts CSRF tokens into forms by default. When combined with Thymeleaf, <input type="hidden" name="_csrf" ...> is added automatically, so you don’t need to think about it.

Summary

Thymeleaf works very well with Spring Boot, and the fact that it starts running just by adding the starter is delightful.

  • Return template names from @Controller to create HTML responses
  • 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 displays based on authentication state with thymeleaf-extras-springsecurity6

It’s a different approach from REST APIs, but for scenarios like admin panels and internal tools where you want to “build HTML on the backend,” it remains a thoroughly practical option even today. Give it a try.