Thymeleaf(タイムリーフ)入門:Spring BootでHTMLを返す

1. Thymeleafとは?(テンプレートの概要)

Thymeleaf は、Spring Bootでよく使われる HTMLテンプレートエンジンです。
テンプレートエンジンとは、HTMLの中に「埋め込み用の目印(式)」を書いておき、サーバー側でその部分を置き換えて 完成したHTMLを返す仕組みのことです。

用語の定義

2. Spring Bootでの基本構造(どこにHTMLを置く?)

ThymeleafのテンプレートHTMLは、通常次の場所に置きます。

src/main/resources/templates/
  └─ users.html

そして Controller から return "users"; と返すと、 Spring Boot は templates/users.html を探して表示します。

3. ControllerからHTMLを返す方法(最小の基本)

ControllerでHTMLを返す場合は、通常 @RestController ではなく @Controller を使います。
@RestController は主にJSONを返す用途です)

Controller例(users.htmlを表示する)
@Controller
public class UserPageController {

  @GetMapping("/users")
  public String users(Model model) {

    // Viewに渡すデータをModelに入れる(キー名 "title")
    model.addAttribute("title", "ユーザー一覧");

    // templates/users.html を表示する
    return "users";
  }
}

ポイント:
return "users"; は「users.html」というファイル名そのものではなく、
テンプレート名(拡張子なし)を返しています。

4. Thymeleafテンプレート側(Modelの値を表示する)

Thymeleafでは、HTMLに th:text などの属性を書いて、Modelの値を埋め込みます。

templates/users.html の例(titleを表示)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title th:text="${title}">タイトル</title>
</head>
<body>
  <h1 th:text="${title}">ユーザー一覧</h1>
</body>
</html>

${title} は「Modelに入れた title の値」を意味します。
Controllerで model.addAttribute("title", "ユーザー一覧"); としたので、 ここには「ユーザー一覧」が表示されます。

5. よく使うThymeleafの書き方(超基本)

① 文字を表示する(th:text)
<span th:text="${message}">ここが置き換わる</span>
② 繰り返し表示する(th:each)

たとえば Model に users というリストを渡している場合、 次のように繰り返し表示できます。

<ul>
  <li th:each="u : ${users}">
    <span th:text="${u.name}">名前</span>
  </li>
</ul>
③ リンクを作る(th:href)
<a th:href="@{/users}">ユーザー一覧へ</a>

6. まとめ(初心者が覚えるべき最小ルール)

ここまで理解できれば、Spring BootでHTMLを返す基本は完成です。
次のステップは「フォーム送信(POST)」「入力チェック」「エラーメッセージ表示」です。

Thymeleaf入門(続き):フォーム送信・バリデーション・レイアウト共通化

7. GETでフォーム表示 → POSTで送信処理(基本の流れ)

用語の定義

Spring Boot + Thymeleafでは、まず GETでフォームを表示し、次に POSTで送信するのが基本です。

7-1. 入力用DTO(フォームの箱)を用意する

フォーム入力は Entity(DBの1行)ではなく、まず DTO(入力用の箱)に受け取るのが安全です。

// 入力用DTO(フォームの箱)
// ※後のバリデーション用アノテーションは次章で説明します
public class UserForm {
  private String name;
  private Integer age;

  public String getName() { return name; }
  public void setName(String name) { this.name = name; }

  public Integer getAge() { return age; }
  public void setAge(Integer age) { this.age = age; }
}
7-2. Controller(GETで表示、POSTで送信)
@Controller
@RequestMapping("/users")
public class UserPageController {

  private final UserService userService;

  public UserPageController(UserService userService) {
    this.userService = userService;
  }

  // ① GET:フォーム表示
  @GetMapping("/new")
  public String showForm(Model model) {
    model.addAttribute("userForm", new UserForm());
    return "user-form"; // templates/user-form.html
  }

  // ② POST:送信処理(まずはバリデーション無し版)
  @PostMapping("/new")
  public String submitForm(@ModelAttribute("userForm") UserForm form) {

    userService.register(form.getName(), form.getAge());

    // PRGパターン:POST後はリダイレクト(再送信防止)
    return "redirect:/users";
  }
}

ポイント:
@ModelAttribute により、フォームの入力が UserForm に自動で詰められます(バインド)。

@Controller の @GetMapping メソッドと Model の関係

結論(先に要点)

@GetMapping のメソッド引数に Model は必須ではありません。
ModelHTML(View)にデータを渡したいときだけ 使用します。


なぜ Model を引数に書けるのか?

Spring Boot(正確には Spring MVC)では、Controller メソッドの引数を Spring が自動で用意して渡す仕組みがあります。

この仕組みを 引数の自動解決(HandlerMethodArgumentResolver) と呼びます。

そのため、Controller メソッドでは 「自分で new して作ったオブジェクト」ではなく、 Spring に用意してほしいものを引数に書く という考え方になります。


Model を使わない例(画面を表示するだけ)

@GetMapping("/about")
public String about() {
  // データを渡さず、HTMLを表示するだけ
  return "about";
}

このように、固定ページなど View に渡すデータが無い場合は Model は不要です。


Model を使う例(HTMLに値を渡したい場合)

@GetMapping("/users")
public String users(Model model) {

  // Viewに渡すデータを Model に入れる
  model.addAttribute("title", "ユーザー一覧");

  return "users";
}

ModelController から View(Thymeleaf)へデータを渡すための箱 です。

上記の場合、HTML側では ${title} として 「ユーザー一覧」が表示されます。


Controller メソッドで使える主な引数

Model 以外にも、 Spring は状況に応じてさまざまな引数を自動で渡してくれます。

引数 意味
Model View(HTML)に渡すデータ箱
@RequestParam URLのクエリパラメータ
@PathVariable URLの一部の値
HttpServletRequest HTTPリクエスト全体
Principal ログイン中のユーザー情報

書いてはいけない引数の例

// ❌ Spring はどう用意すればいいか分からない
@GetMapping("/users")
public String users(String title) {
  return "users";
}

Spring は 「その型のオブジェクトをどうやって作ればいいのか」 分からない引数は渡せません。


初心者が混乱しやすいポイント

Model model を見ると、 「どこで new しているの?」と思いがちですが、 自分で new する必要はありません。

Spring が裏で作り、Controller に渡しています。


まとめ

一言で言うと:
Controller メソッドの引数は、 Spring に対する「これをください」という宣言 です。

7-3. Thymeleafテンプレート(フォームHTML)

テンプレート側では th:objectth:field を使うと楽です。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>ユーザー登録</title>
</head>
<body>

  <h1>ユーザー登録</h1>

  <form th:action="@{/users/new}" th:object="${userForm}" method="post">

    <div>
      <label>名前</label>
      <input type="text" th:field="*{name}" />
    </div>

    <div>
      <label>年齢</label>
      <input type="number" th:field="*{age}" />
    </div>

    <button type="submit">登録</button>
  </form>

</body>
</html>

th:object="${userForm}" はフォームの「入力対象オブジェクト」を指定します。
th:field="*{name}" は、そのオブジェクトの name プロパティに結びつきます。


8. バリデーション(@Valid)とエラー表示(th:errors)

用語の定義
8-1. 依存関係(バリデーションを使う準備)

Spring Bootでは通常、次を入れるとバリデーションが使えます(Maven/GradleいずれでもOK)。

// Maven例(pom.xml)
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
8-2. DTOにバリデーションルールを書く
import jakarta.validation.constraints.*;

public class UserForm {

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

  @NotNull(message = "年齢は必須です")
  @Min(value = 0, message = "年齢は0以上で入力してください")
  @Max(value = 120, message = "年齢は120以下で入力してください")
  private Integer age;

  // getter/setter 省略
}
8-3. Controllerで @Valid と BindingResult を使う

BindingResultは必ず、@Validの直後の引数に置きます。

@PostMapping("/new")
public String submitForm(@Valid @ModelAttribute("userForm") UserForm form,
                         BindingResult bindingResult) {

  if (bindingResult.hasErrors()) {
    // エラーがあれば、同じ画面に戻す(入力値は保持される)
    return "user-form";
  }

  userService.register(form.getName(), form.getAge());
  return "redirect:/users";
}
8-4. Thymeleafでエラー表示(th:errors / th:if)
<form th:action="@{/users/new}" th:object="${userForm}" method="post">

  <div>
    <label>名前</label>
    <input type="text" th:field="*{name}" />

    <!-- nameにエラーがあるときだけ表示 -->
    <div th:if="${#fields.hasErrors('name')}" th:errors="*{name}">エラー</div>
  </div>

  <div>
    <label>年齢</label>
    <input type="number" th:field="*{age}" />

    <div th:if="${#fields.hasErrors('age')}" th:errors="*{age}">エラー</div>
  </div>

  <button type="submit">登録</button>
</form>

#fields.hasErrors('name') は「nameにエラーがあるか?」を判定します。
th:errors="*{name}" はそのエラーメッセージを表示します。


9. レイアウト共通化(header/footerをテンプレ化)

用語の定義
9-1. 部品テンプレートを作る(header/footer)

まず、テンプレート部品用フォルダを作ると管理しやすいです。

src/main/resources/templates/
  ├─ fragments/
  │   ├─ header.html
  │   └─ footer.html
  └─ user-form.html
fragments/header.html
<!-- fragments/header.html -->
<div xmlns:th="http://www.thymeleaf.org"
     th:fragment="header(title)">

  <header>
    <h1 th:text="${title}">タイトル</h1>
    <nav>
      <a th:href="@{/users}">一覧</a> |
      <a th:href="@{/users/new}">新規登録</a>
    </nav>
    <hr>
  </header>

</div>
fragments/footer.html
<!-- fragments/footer.html -->
<div xmlns:th="http://www.thymeleaf.org"
     th:fragment="footer">

  <hr>
  <footer>
    <small>© My App</small>
  </footer>

</div>
9-2. 各ページから呼び出す(th:replace)

例として、user-form.html にヘッダー/フッターを差し込みます。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>ユーザー登録</title>
</head>
<body>

  <!-- header(title) に "ユーザー登録" を渡す -->
  <div th:replace="~{fragments/header :: header('ユーザー登録')}"></div>

  <main>
    <form th:action="@{/users/new}" th:object="${userForm}" method="post">

      <div>
        <label>名前</label>
        <input type="text" th:field="*{name}" />
        <div th:if="${#fields.hasErrors('name')}" th:errors="*{name}">エラー</div>
      </div>

      <div>
        <label>年齢</label>
        <input type="number" th:field="*{age}" />
        <div th:if="${#fields.hasErrors('age')}" th:errors="*{age}">エラー</div>
      </div>

      <button type="submit">登録</button>
    </form>
  </main>

  <div th:replace="~{fragments/footer :: footer}"></div>

</body>
</html>
まとめ(この章で覚えること)

次のステップとして、実務では Service層での登録処理(重複チェックやDB保存)や @Transactional(トランザクション)を学ぶと理解がつながります。

参考:Thymeleafの式の種類(超基本)

Thymeleafにはいろいろな式がありますが、初心者がまず覚えるべきは次の3つです。

サンプルコード


        


  
  ユーザー一覧
  



ユーザー一覧

id userName email
1 taka_o taka@example.com
     
    @Controller
@RequestMapping("/users")
public class UserPageController {

  private final UserService userService;

  public UserPageController(UserService userService) {
    this.userService = userService;
  }

  // ユーザー一覧画面を表示
  @GetMapping
  public String showUsers(Model model) {

    // 本来はDBから取得(Service経由)
    List users = userService.findAll();

    // View(HTML)に渡す
    model.addAttribute("users", users);

    // templates/users.html を表示
    return "users";
  }
}

    @Controller
@RequestMapping("/users")
public class UserPageController {

  private final UserService userService;

  public UserPageController(UserService userService) {
    this.userService = userService;
  }

  // ユーザー一覧画面を表示
  @GetMapping
  public String showUsers(Model model) {

    // 本来はDBから取得(Service経由)
    List users = userService.findAll();

    // View(HTML)に渡す
    model.addAttribute("users", users);

    // templates/users.html を表示
    return "users";
  }
}

Thymeleaf 属性一覧(初心者向け)

Thymeleaf では、HTMLタグに th:〜 という属性を付けることで、 Controller から渡されたデータを HTML に埋め込みます。


1. th:text(文字を表示する)

用途: タグの中身の文字を置き換える

<td th:text="${user.email}">taka@example.com</td>

説明:
${user.email} の値で、タグの中身が完全に置き換わります。
taka@example.com は HTML を直接開いたとき用のダミー表示です。


2. th:each(繰り返し表示)

用途: リストをループして表示する

<tr th:each="user : ${users}">
  <td th:text="${user.userName}">taka_o</td>
</tr>

説明:
${users} は Controller から渡されたリストです。
1件ずつ user という変数に入れて繰り返します。


3. th:if(条件付き表示)

用途: 条件が true のときだけ表示する

<div th:if="${user.email != null}">
  <span th:text="${user.email}">mail</span>
</div>

説明:
条件が false の場合、この要素は HTML にすら出力されません。


4. th:unless(条件が false のとき表示)

用途: th:if の逆

<p th:unless="${users.size() > 0}">
  データがありません
</p>

5. th:href(リンクURLを作る)

用途: aタグのリンク先を動的に作る

<a th:href="@{/users/{id}(id=${user.id})}">
  詳細
</a>

説明:
URL の {id} 部分に user.id が入ります。
実行時:/users/3 のようになります。


6. th:action(フォームの送信先)

用途: form の送信先URLを指定

<form th:action="@{/users/new}" method="post">

7. th:object(フォーム全体のバインド)

用途: フォーム入力の対象オブジェクトを指定

<form th:object="${userForm}">

説明:
このフォーム全体が userForm に結び付きます。


8. th:field(フォーム入力とプロパティを結び付ける)

用途: input と Java のプロパティを自動連携

<input type="text" th:field="*{userName}" />

説明:
*{}th:object で指定したオブジェクトを指します。
name属性・value属性も自動生成されます。


9. th:errors(バリデーションエラー表示)

用途: 入力エラーを表示

<div th:errors="*{email}">エラー</div>

説明:
@Valid で検出されたエラーメッセージが表示されます。


10. th:classappend(CSSクラスを条件付きで追加)

用途: エラー時などにクラスを追加

<input th:field="*{email}"
       th:classappend="${#fields.hasErrors('email')} ? 'error' : ''" />

11. th:replace / th:insert(テンプレート部品の読み込み)

用途: ヘッダー・フッターなどの共通化

<div th:replace="~{fragments/header :: header('タイトル')}"></div>

説明:
別ファイルで定義したテンプレート部品をここに差し込みます。


12. よく使う式(覚えておくと便利)

${user.name}          // プロパティ参照
${users.size()}       // 件数
${#lists.isEmpty(users)} // 空チェック
${#fields.hasErrors('name')} // 入力エラー判定

まとめ(最低限これを覚えればOK)

ここまで理解できれば、
Spring Boot + Thymeleaf の画面実装は一通り書けるレベルです。

Thymeleaf を使うための pom.xml 設定

結論(先に要点)

Spring Boot で Thymeleaf を使うには、 pom.xml に専用の starter を1つ追加するだけです。


追加する依存関係

以下を pom.xml<dependencies> 内に追加します。

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

これで、

自動的に有効になります。


なぜ starter を入れるだけで動くのか?

spring-boot-starter-thymeleaf は、 Thymeleaf を使うために必要な 複数のライブラリと設定をまとめたパッケージです。

そのため、次のような作業は不要です。

Spring Boot の「自動設定(Auto Configuration)」が すべて裏でやってくれます。


Thymeleaf を入れると何が起きるか

上記 dependency を追加すると、Spring Boot は次を自動で行います。

  1. templates/ フォルダを View の探索対象にする
  2. return "users";users.html を探す
  3. th:textth:each を解釈する

よくある疑問

Q. Thymeleaf を使わない場合は?

この dependency を入れなければ、 Spring Boot は HTML テンプレートとして処理しません。
@Controllerreturn "users"; と書くと エラーになります。

Q. application.properties に設定は必要?

基本的には 不要です。
デフォルト設定で次の場所が使われます。

src/main/resources/templates/*.html

参考:application.properties(任意)

学習用としては不要ですが、 以下のような設定も存在します。

# テンプレートのキャッシュを無効化(開発中に便利)
spring.thymeleaf.cache=false

まとめ

一言で言うと:
Thymeleaf は 「入れた瞬間から使えるように設計されている」 テンプレートエンジンです。

th:field="*{name}" を Controller で受け取る仕組み

結論(先に要点)

<input type="text" th:field="*{name}" /> の値は、
Controller の引数で受け取る「フォーム用クラス(DTO)」の name プロパティに自動で入ります。


1. 前提となる考え方(重要)

Thymeleaf のフォーム入力は、 HTML → Controller に直接 String を渡すのではありません。
いったん フォーム用のJavaクラス(DTO) にまとめて受け取ります。

この仕組みを データバインディング(Binding) と呼びます。


2. フォーム用クラス(受け取り用の箱)

// フォーム入力を受け取るDTO
public class UserForm {

  private String name;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
}

ポイント:
th:field="*{name}" は、 このクラスの name プロパティと 1対1で対応します。


3. HTML 側の書き方(対応関係)

<form th:action="@{/users/new}"
      th:object="${userForm}"
      method="post">

  <input type="text" th:field="*{name}" />

  <button type="submit">送信</button>
</form>

ここでの対応関係は次のとおりです。

HTML 意味
th:object="${userForm}" Controllerに渡すオブジェクト名
th:field="*{name}" そのオブジェクトの name プロパティ

4. Controller 側の正しい受け取り方

@Controller
@RequestMapping("/users")
public class UserPageController {

  @PostMapping("/new")
  public String submit(
      @ModelAttribute("userForm") UserForm userForm
  ) {

    // input に入力された値がここに入る
    String name = userForm.getName();

    System.out.println(name);

    return "result";
  }
}

Spring が自動で、 フォーム入力値を UserForm に詰めてから Controller に渡します。


5. @ModelAttribute は省略できる?

はい、省略できます。

@PostMapping("/new")
public String submit(UserForm userForm) {
  String name = userForm.getName();
  return "result";
}

Spring が引数の型を見て判断します。
ただし初心者のうちは、 何が起きているか分かりやすいので明示するのがおすすめです。


6. よくある間違い

❌ String で直接受け取ろうとする

@PostMapping("/new")
public String submit(String name) { // NG
  return "result";
}

Spring は 「どの入力項目の name なのか」 を判断できないため、正しく動きません。

❌ プロパティ名が一致していない

// HTML
<input th:field="*{username}" />

// Java
private String name; // NG

th:field の名前と Java のプロパティ名は必ず一致させます。


まとめ

一言で言うと:
th:fieldController の引数オブジェクトへの「直通ルート」 です。

一覧テーブル+選択→編集→保存の画面:Controllerの書き方(典型パターン)

全体の考え方(何をControllerでやる?)

「行をクリックすると input に表示」は、サーバー(Controller)だけではできません
クリックはブラウザ内のイベントなので、JavaScriptで input に値を入れるのが一般的です。
(もしくは、クリックで /edit?id=... に遷移してサーバー側で再表示、という方法もあります)


パターンA:同一ページで編集する(JSでクリック→フォームへ反映)

① Controller(GETで一覧+フォーム表示、POSTで更新)
@Controller
@RequestMapping("/users")
public class UserEditPageController {

  private final UserService userService;

  public UserEditPageController(UserService userService) {
    this.userService = userService;
  }

  // 画面表示:一覧とフォーム(初期は空)
  @GetMapping
  public String page(Model model) {
    model.addAttribute("users", userService.findAll());

    // フォーム用DTO(空)
    model.addAttribute("userForm", new UserForm());

    return "users-edit"; // templates/users-edit.html
  }

  // 更新:フォーム送信を受け取って保存
  @PostMapping("/update")
  public String update(@ModelAttribute("userForm") UserForm form) {
    userService.update(form); // id を含めて更新する想定
    return "redirect:/users"; // 画面再表示(再送信防止)
  }
}

ポイント:

② UserForm(idも持たせる:どの行を更新するか判別するため)
public class UserForm {
  private Long id;
  private String userName;
  private String email;

  public Long getId() { return id; }
  public void setId(Long id) { this.id = id; }

  public String getUserName() { return userName; }
  public void setUserName(String userName) { this.userName = userName; }

  public String getEmail() { return email; }
  public void setEmail(String email) { this.email = email; }
}
③ Thymeleaf(users-edit.html:一覧+フォーム+クリックで入力反映)
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Users Edit</title>
  <style>
    table { border-collapse: collapse; width: 100%; }
    th, td { border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
    tr.clickable { cursor: pointer; }
    tr.clickable:hover { background: #fafbff; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
    .card { background: #fff; padding: 16px; border-radius: 12px; }
    body { background: #f6f7fb; padding: 20px; font-family: system-ui, sans-serif; }
    label { display:block; margin-top: 10px; font-size: 12px; color:#555; }
    input { width: 100%; padding: 8px; }
    button { margin-top: 12px; padding: 10px 14px; }
  </style>
</head>

<body>
  <div class="grid">

    <div class="card">
      <h2>一覧(行クリックで右のフォームに表示)</h2>
      <table>
        <thead>
          <tr><th>id</th><th>userName</th><th>email</th></tr>
        </thead>
        <tbody>
          <tr class="clickable"
              th:each="u : ${users}"
              th:attr="data-id=${u.id}, data-username=${u.userName}, data-email=${u.email}">
            <td th:text="${u.id}">1</td>
            <td th:text="${u.userName}">taka_o</td>
            <td th:text="${u.email}">taka@example.com</td>
          </tr>
        </tbody>
      </table>
    </div>

    <div class="card">
      <h2>編集フォーム</h2>

      <form th:action="@{/users/update}" th:object="${userForm}" method="post">

        <!-- どのユーザーを更新するか:hiddenでidを送る -->
        <input type="hidden" id="id" th:field="*{id}" />

        <label>userName</label>
        <input type="text" id="userName" th:field="*{userName}" />

        <label>email</label>
        <input type="text" id="email" th:field="*{email}" />

        <button type="submit">保存(更新)</button>
      </form>

      <p style="font-size:12px;color:#666;margin-top:10px">
        ※行をクリックするとフォームに値が入ります(ブラウザ内のJavaScriptで反映)。
      </p>
    </div>

  </div>

  <script>
    // 行クリック → data-* を input に入れる
    document.querySelectorAll("tr.clickable").forEach(tr => {
      tr.addEventListener("click", () => {
        document.getElementById("id").value = tr.dataset.id;
        document.getElementById("userName").value = tr.dataset.username;
        document.getElementById("email").value = tr.dataset.email;
      });
    });
  </script>
</body>
</html>

ここがポイント:
行に data-id / data-username / data-email を仕込んで、クリック時に input に入れています。
こうすると「1画面で一覧+編集」が成立します。


パターンB:クリックすると別URLへ遷移して編集(JS不要)

「JSがまだ難しい」場合は、クリックをリンクにして /users/edit/{id} に移動し、サーバー側でフォームに値を入れて表示する方法がよく使われます。

Controller例(JSなし)
@GetMapping("/edit/{id}")
public String edit(@PathVariable Long id, Model model) {
  model.addAttribute("users", userService.findAll()); // 左に一覧を出すなら
  model.addAttribute("userForm", userService.findFormById(id)); // 取得してフォームに詰める
  return "users-edit";
}

パターンBの特徴:


結論:Controllerはどう書く?

完全版:一覧+選択→編集(@Valid)→更新(save)+削除ボタン付き

このサンプルでできること

※「行クリックでフォームに反映」はブラウザ側のイベントなので、JavaScript を最小で使います
JSなし派なら「クリックで /users/edit/{id} に遷移」版にできます。


1) pom.xml(バリデーションを使うため)

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

2) Entity(DBの1行:User)

@Entity
public class User {

  @Id
  @GeneratedValue
  private Long id;

  private String userName;

  private String email;

  // getter / setter(省略しない場合は必ず用意)
  public Long getId() { return id; }
  public void setId(Long id) { this.id = id; }

  public String getUserName() { return userName; }
  public void setUserName(String userName) { this.userName = userName; }

  public String getEmail() { return email; }
  public void setEmail(String email) { this.email = email; }
}

3) Form(DTO:画面入力の受け取り箱)+ @Valid ルール

Entity を直接フォームに使わず、まず Form(DTO)で受け取るのが安全です。

import jakarta.validation.constraints.*;

public class UserForm {

  // 更新対象を特定するために必要(hiddenで送る)
  @NotNull(message = "id は必須です(行を選択してください)")
  private Long id;

  @NotBlank(message = "userName は必須です")
  @Size(max = 20, message = "userName は20文字以内で入力してください")
  private String userName;

  @NotBlank(message = "email は必須です")
  @Email(message = "email の形式が正しくありません")
  @Size(max = 100, message = "email は100文字以内で入力してください")
  private String email;

  public Long getId() { return id; }
  public void setId(Long id) { this.id = id; }

  public String getUserName() { return userName; }
  public void setUserName(String userName) { this.userName = userName; }

  public String getEmail() { return email; }
  public void setEmail(String email) { this.email = email; }
}

4) Repository(DB操作)

public interface UserRepository extends JpaRepository<User, Long> {
  // 今回は findAll / findById / save / deleteById を使うので追加メソッドは不要
}

5) Service(更新/削除をここに集約)

更新は JPA の save() で実現します。
重要:id が入っている Entity を save すると UPDATE になります(DBにそのidが存在する前提)。

@Service
public class UserService {

  private final UserRepository userRepository;

  public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  public List<User> findAll() {
    return userRepository.findAll();
  }

  // FormをEntityに反映して更新(save)
  public void update(UserForm form) {
    // 既存を取得(存在しない場合は例外)
    User user = userRepository.findById(form.getId())
        .orElseThrow(() -> new IllegalArgumentException("指定idのユーザーが存在しません: " + form.getId()));

    // 更新したい項目だけ反映
    user.setUserName(form.getUserName());
    user.setEmail(form.getEmail());

    // save:idがあるので UPDATE 相当
    userRepository.save(user);
  }

  public void delete(Long id) {
    userRepository.deleteById(id);
  }
}

6) Controller(GETで画面表示、POSTで更新、POSTで削除)

BindingResult は必ず @Valid の直後に置きます。
エラーがあれば同じ画面を返し、一覧も再度 Model に入れ直します(画面に一覧が必要なので)。

@Controller
@RequestMapping("/users")
public class UserEditPageController {

  private final UserService userService;

  public UserEditPageController(UserService userService) {
    this.userService = userService;
  }

  // 画面表示:一覧 + 空フォーム
  @GetMapping
  public String page(Model model) {
    model.addAttribute("users", userService.findAll());
    model.addAttribute("userForm", new UserForm()); // 初期は空
    return "users-edit";
  }

  // 更新:@Valid で入力チェック → OKなら更新
  @PostMapping("/update")
  public String update(@Valid @ModelAttribute("userForm") UserForm form,
                       BindingResult result,
                       Model model) {

    if (result.hasErrors()) {
      // エラー時も一覧は必要
      model.addAttribute("users", userService.findAll());
      return "users-edit";
    }

    userService.update(form);
    return "redirect:/users";
  }

  // 削除:idを受け取って削除
  @PostMapping("/delete")
  public String delete(@RequestParam("id") Long id) {
    userService.delete(id);
    return "redirect:/users";
  }
}

7) Thymeleaf(templates/users-edit.html)

左の行をクリックすると、右フォームの hidden(id) と input(userName/email) に値が入ります。
更新ボタンはフォーム送信(POST /users/update)、削除ボタンは別フォーム(POST /users/delete)です。





  
  
  Users Edit
  



一覧(行クリックで右に反映)

id userName email 操作
1 taka_o taka@example.com

※行をクリックすると右のフォームに値が入ります(JSで反映)。

編集フォーム(@Valid でチェック)

id error
userName error
email error
※保存前に必ず行をクリックして選択してください
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Users Edit</title>
  <style>
    body { background:#f6f7fb; padding:20px; font-family:system-ui, sans-serif; }
    .grid { display:grid; grid-template-columns: 1.2fr 1fr; gap:16px; max-width: 980px; margin:0 auto; }
    .card { background:#fff; padding:16px; border-radius:12px; box-shadow: 0 6px 16px rgba(0,0,0,.06); }
    table { border-collapse: collapse; width: 100%; }
    th, td { border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
    tr.clickable { cursor: pointer; }
    tr.clickable:hover td { background:#fafbff; }
    label { display:block; margin-top:10px; font-size:12px; color:#555; }
    input { width:100%; padding:8px; box-sizing:border-box; }
    .row { display:flex; gap:10px; margin-top:12px; align-items:center; }
    button { padding:10px 14px; cursor:pointer; }
    .error { color:#d92d20; font-size:12px; margin-top:6px; }
    .hint { font-size:12px; color:#667085; margin-top:8px; }
    .danger { border:1px solid #f04438; background:#fff5f5; }
  </style>
</head>
<body>

<div class="grid">

  <div class="card">
    <h2>一覧(行クリックで右に反映)</h2>

    <table>
      <thead>
        <tr><th>id</th><th>userName</th><th>email</th><th>操作</th></tr>
      </thead>
      <tbody>

        <tr class="clickable"
            th:each="u : ${users}"
            th:attr="data-id=${u.id}, data-username=${u.userName}, data-email=${u.email}">

          <td th:text="${u.id}">1</td>
          <td th:text="${u.userName}">taka_o</td>
          <td th:text="${u.email}">taka@example.com</td>

          <td>
            <!-- 削除は別フォーム(行ごとに送る) -->
            <form th:action="@{/users/delete}" method="post" style="margin:0">
              <input type="hidden" name="id" th:value="${u.id}" />
              <button type="submit" class="danger">削除</button>
            </form>
          </td>

        </tr>

      </tbody>
    </table>

    <p class="hint">※行をクリックすると右のフォームに値が入ります(JSで反映)。</p>
  </div>

  <div class="card">
    <h2>編集フォーム(@Valid でチェック)</h2>

    <form th:action="@{/users/update}" th:object="${userForm}" method="post">

      <!-- 更新対象のid(クリックで入る) -->
      <input type="hidden" id="id" th:field="*{id}" />

      <!-- id のエラー表示(行を選ばずに保存した場合など) -->
      <div th:if="${#fields.hasErrors('id')}" th:errors="*{id}" class="error">id error</div>

      <label>userName</label>
      <input type="text" id="userName" th:field="*{userName}" />
      <div th:if="${#fields.hasErrors('userName')}" th:errors="*{userName}" class="error">userName error</div>

      <label>email</label>
      <input type="text" id="email" th:field="*{email}" />
      <div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error">email error</div>

      <div class="row">
        <button type="submit">保存(更新)</button>
        <span class="hint">※保存前に必ず行をクリックして選択してください</span>
      </div>

    </form>
  </div>

</div>

<script>
  // 行クリック → data-* をフォームに反映
  document.querySelectorAll("tr.clickable").forEach(tr => {
    tr.addEventListener("click", () => {
      document.getElementById("id").value = tr.dataset.id;
      document.getElementById("userName").value = tr.dataset.username;
      document.getElementById("email").value = tr.dataset.email;
    });
  });
</script>

</body>
</html>

8) よくある疑問(初心者が詰まるポイント)

Q. 更新はなぜ save() でできるの?

JPA では、id を持つ Entity を save すると UPDATE 相当になります(そのidがDBに存在する前提)。
今回は安全のために findById で既存 Entity を取ってから、値を変更して save しています。

Q. エラー時に一覧が消えるのはなぜ?

エラー時に return "users-edit" で同じ画面に戻す場合、
一覧データ(users)も再度 model に入れないと、テンプレート側で表示できません。
そのため、エラー時は model.addAttribute("users", ...) を必ず入れています。

Q. 削除はなぜ別フォーム?

HTML の基本では、ボタン送信はそのフォームの action に送られます。
更新フォームと削除ボタンは送信先が違うため、削除用フォームを別にしています。


まとめ

SSR(Thymeleaf)で「戻る」ボタン:history.back() だとログイン画面まで戻ってしまう問題

結論(先に要点)

history.back() はブラウザ履歴に戻るだけなので、状況によっては ログイン画面まで戻れてしまいます
「Aから来たらAへ、Bから来たらBへ」と戻り先を確実に制御したい場合は、 戻り先を明示的に管理するのが一般的です。


なぜログイン画面まで戻ってしまうのか

例えば履歴が次の順なら、

/login → /menuA → /detail

詳細画面で history.back() を押すと、 履歴どおりに戻るだけなので、

/detail → /menuA → /login

となり、さらに戻すとログイン画面にも戻れてしまいます。
これはバグではなく ブラウザの仕様です。


解決策(実務でよく使われる方法)

① 戻り先をURLで渡す(最も確実・王道)

「どこに戻るか」を returnTo のようなパラメータで持ち回ります。
こうすると、ログイン画面に戻らないように 戻り先を固定できます。

一覧A → 詳細(戻り先を付ける)

<a th:href="@{/detail/{id}(id=${u.id}, returnTo='/menuA')}">詳細</a>

一覧B → 詳細

<a th:href="@{/detail/{id}(id=${u.id}, returnTo='/menuB')}">詳細</a>

Controller(returnTo を Model に渡す)

@GetMapping("/detail/{id}")
public String detail(@PathVariable Long id,
                     @RequestParam(required=false) String returnTo,
                     Model model) {

  model.addAttribute("item", service.findById(id));
  model.addAttribute("returnTo", returnTo != null ? returnTo : "/menuA"); // デフォルト
  return "detail";
}

詳細画面(戻るリンク)

<a th:href="${returnTo}">戻る</a>

メリット:


② history.back() + フォールバック(妥協案)

どうしても戻るを「履歴優先」にしたい場合は、 「ログイン画面に戻りそうなら安全なページへ飛ばす」 というフォールバックを付けます。

<button type="button" onclick="goBack()">戻る</button>

<script>
function goBack() {
  // 前ページがログインっぽいなら安全な戻り先へ
  if (document.referrer && !document.referrer.includes('/login')) {
    history.back();
  } else {
    location.href = '/menuA'; // 安全なデフォルト
  }
}
</script>

注意: document.referrer は状況により空になったり、 セキュリティ設定で消えることがあります(完全ではありません)。


③ Referer(前ページ)に頼る方法(不安定なので補助向き)

リクエストヘッダの Referer を使う方法もありますが、 消えることがあるため「確実な戻り先」にしたい用途には弱いです。


おすすめの結論(実務的な選び方)

一言で:
history.back() は便利ですが「戻りすぎ」を制御できません。
SSRで安全に戻るなら、戻り先を明示的に持ち回すのが王道です。

一覧のページング(Paging)を実現する:Controller + Thymeleaf サンプル

用語の定義(初心者向け)


前提:Repository(JpaRepositoryがあればOK)

JpaRepository を継承していれば、ページング用の findAll(Pageable) が最初から使えます。

public interface UserRepository extends JpaRepository<User, Long> {
  // 追加メソッド不要(findAll(Pageable) が使える)
}

1) Controller(page/size を受け取り、Page を Model に渡す)

URL例:/users?page=0&size=10
pageは0始まり(0=1ページ目)に注意。

@Controller
@RequestMapping("/users")
public class UserPagingController {

  private final UserRepository userRepository;

  public UserPagingController(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @GetMapping
  public String list(
      @RequestParam(name = "page", defaultValue = "0") int page,
      @RequestParam(name = "size", defaultValue = "10") int size,
      Model model
  ) {
    // 例:新しい順にしたいなら Sort を付ける
    Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());

    Page<User> userPage = userRepository.findAll(pageable);

    // Thymeleaf に渡す
    model.addAttribute("userPage", userPage);
    model.addAttribute("users", userPage.getContent()); // 表示用リスト
    model.addAttribute("page", page);
    model.addAttribute("size", size);

    return "users-paging"; // templates/users-paging.html
  }
}

ポイント:


2) Thymeleaf(一覧表示 + 前へ/次へ + ページ番号リンク)

ファイル配置:src/main/resources/templates/users-paging.html

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Users(Paging)</title>
  <style>
    body { background:#f6f7fb; padding:20px; font-family:system-ui, sans-serif; }
    .card { max-width: 960px; margin:0 auto; background:#fff; padding:16px; border-radius:12px; }
    table { border-collapse: collapse; width:100%; }
    th, td { border-bottom: 1px solid #eee; padding:10px; text-align:left; }
    .pager { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:12px; }
    .pager a, .pager span {
      display:inline-block; padding:8px 10px; border:1px solid #e5e7eb; border-radius:10px;
      text-decoration:none; color:#111;
    }
    .pager .disabled { color:#999; background:#f3f4f6; border-color:#eee; }
    .pager .active { font-weight:700; }
    .meta { font-size:12px; color:#667085; margin-top:10px; }
  </style>
</head>

<body>
  <div class="card">
    <h2>ユーザー一覧(ページング)</h2>

    <table>
      <thead>
        <tr>
          <th>id</th>
          <th>userName</th>
          <th>email</th>
        </tr>
      </thead>

      <tbody>
        <tr th:each="u : ${users}">
          <td th:text="${u.id}">1</td>
          <td th:text="${u.userName}">taka_o</td>
          <td th:text="${u.email}">taka@example.com</td>
        </tr>
      </tbody>
    </table>

    <div class="meta">
      全<span th:text="${userPage.totalElements}">0</span>件 /
      <span th:text="${userPage.totalPages}">0</span>ページ中
      <span th:text="${userPage.number + 1}">1</span>ページ目
      (1ページ<span th:text="${userPage.size}">10</span>件)
    </div>

    <!-- ページャ -->
    <div class="pager">

      <!-- 前へ(hasPrevious が false なら押せない表示) -->
      <span class="disabled" th:if="${!userPage.hasPrevious()}">前へ</span>
      <a th:if="${userPage.hasPrevious()}"
         th:href="@{/users(page=${userPage.number - 1}, size=${userPage.size})}">前へ</a>

      <!-- ページ番号(0..totalPages-1 をループ) -->
      <a th:each="i : ${#numbers.sequence(0, userPage.totalPages - 1)}"
         th:href="@{/users(page=${i}, size=${userPage.size})}"
         th:text="${i + 1}"
         th:classappend="${i == userPage.number} ? ' active' : ''">1</a>

      <!-- 次へ -->
      <span class="disabled" th:if="${!userPage.hasNext()}">次へ</span>
      <a th:if="${userPage.hasNext()}"
         th:href="@{/users(page=${userPage.number + 1}, size=${userPage.size})}">次へ</a>

    </div>

  </div>
</body>
</html>

3) 重要ポイント(初心者がハマりやすい)

page は 0 始まり

Spring Data の PageRequest0 が1ページ目です。
画面表示だけ userPage.number + 1 にして「人間向け」にします。

totalPages が 0 のとき sequence が壊れる

データが0件だと totalPages = 0 になり、 #numbers.sequence(0, -1) となって困ることがあります。
実務では次のようにガードします(任意)。

<div class="pager" th:if="${userPage.totalPages > 0}">
  ...
</div>

まとめ