Thymeleaf(タイムリーフ)とは?読み方とSpring Bootでの使い方・th:属性を実例で解説
Thymeleafは「タイムリーフ」と読む、Java向けのサーバーサイドテンプレートエンジンです。Spring Bootの公式スターターが用意されており、Javaで画面を返すWebアプリケーションの標準的な選択肢になっています。最大の特徴は、テンプレートがそのままブラウザで開ける正しいHTMLであること。この記事では読み方と基礎の整理から、Spring Bootへの導入、th:textやth:eachといった主要属性、フォームバインディング、フラグメントによる共通化までをコード付きで扱います。最後に、Thymeleaf 3.1で式オブジェクトが削除されたことによる移行時のエラー(Spring Boot 3以降で踏みやすい)まで踏み込みます。
まとめ:Thymeleafの要点
- 読み方は「タイムリーフ」。thyme(タイム=ハーブ)+leafで、ロゴもタイムの葉。
- 最新は3.1.5.RELEASE(2026年4月21日リリース、Java SE 8以降が必要)。
- テンプレートは
th:属性を既存HTMLタグに足す形なので、テンプレートファイルをブラウザで直接開いても崩れない(ナチュラルテンプレート)。 - Spring Bootでは
spring-boot-starter-thymeleafを依存に追加し、src/main/resources/templates/にHTMLを置くだけで動く。設定ファイルへの記述は不要。 - 式は4種類。
${...}=コンテキスト変数、*{...}=th:objectで選択した対象のプロパティ、@{...}=URL、#{...}=メッセージ(多言語化)。 - Thymeleaf 3.1で
#request・#session・#response・#servletContextが削除された。Spring Boot 3以降へ上げた際にテンプレートが例外で落ちる主因はこれ。 - 画面をサーバーで組み立てないSPA構成(React・Vueでフロントを分離)では、そもそもThymeleafを入れる理由がない。
Thymeleafの読み方と、ナチュラルテンプレートという設計思想
Thymeleafの読み方は「タイムリーフ」です。英語の thyme(ハーブのタイム)は先頭のhを発音せず「タイム」となるため、綴りから受ける印象と読みが一致しません。「シームリーフ」「サイムリーフ」と読まれることが多いのはこのためです。公式ロゴもタイムの葉をモチーフにしています。
設計上の中核は「ナチュラルテンプレート」です。Thymeleafのテンプレートは、専用の独自記法でHTMLを分断せず、HTMLの属性として式を書きます。
<p th:text="${message}">ここはダミーのテキストです</p>
この<p>タグは、Thymeleafを通さずブラウザで直接開けば「ここはダミーのテキストです」と表示されます。サーバーを起動してレンダリングすれば、th:textが中身をmessageの値に差し替えます。つまり、同じファイルが「デザイナーが確認できる静的HTML」と「動くテンプレート」を兼ねます。JSPの<% %>のようにHTMLとして壊れた記法を混ぜないので、静的モックとテンプレートを二重管理する必要がありません。デザイナーとエンジニアが同じファイルを触る現場では、この一点がThymeleafを選ぶ最大の理由になります。
Spring Bootでの導入手順
Spring Bootには公式スターターがあり、依存を1つ足すだけで自動設定まで完了します。Mavenの場合はpom.xmlに次を追加します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Gradleならbuild.gradleに1行です。
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
スターターが入ると、テンプレートの探索パスはsrc/main/resources/templates/、拡張子は.htmlが既定になります。コントローラが返す文字列がこのディレクトリ配下のファイル名に対応します。
@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("message", "こんにちは、Thymeleaf");
return "hello"; // templates/hello.html を描画する
}
}
テンプレート側は名前空間を宣言しておくと、IDEがth:属性を補完・検証できます(宣言が無くても描画自体は動きます)。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Hello</title></head>
<body>
<h1 th:text="${message}">プレースホルダ</h1>
</body>
</html>
開発時にテンプレートの修正が画面に反映されない場合、原因はほぼテンプレートキャッシュです。spring-boot-devtoolsを依存に入れていれば、devtoolsが有効な間は既定でキャッシュが無効になりますが、入れていないならapplication.propertiesで明示的に切ります。本番では必ず既定のtrueに戻してください(毎リクエストでテンプレートを再パースするため、性能に直接響きます)。
spring.thymeleaf.cache=false
使用するSpring Bootのバージョンによって必要なJavaやJakarta EEの世代が変わります。前提の整理はSpring Bootのバージョン一覧とサポート期限を、4系での変更点はSpring Boot 4とは?最新バージョン4.1の変更点を参照してください。
${}・*{}・@{}・#{} の使い分け
Thymeleafでつまずきやすいのが記号の違いです。4種類は評価する対象が違うだけで、混ぜて使えます。
| 記法 | 名称 | 評価対象 | 典型例 |
|---|---|---|---|
${...} |
変数式 | コンテキスト変数(Modelに入れた値) | ${user.name} |
*{...} |
選択変数式 | th:objectで選択中のオブジェクト | *{name} |
@{...} |
リンクURL式 | コンテキストパスを補ったURL | @{/users/{id}(id=${user.id})} |
#{...} |
メッセージ式 | プロパティファイルの外部化メッセージ | #{home.welcome} |
${}はOGNL(Spring統合時はSpEL)でコンテキスト全体のマップを辿ります。*{}はth:objectで選んだオブジェクトを起点にするので、次の2つは同じ意味になります。
<div th:object="${user}">
<p th:text="*{name}"></p> <!-- 選択変数式 -->
<p th:text="${user.name}"></p> <!-- 変数式・同じ結果 -->
</div>
@{}を使う理由は、アプリをサブパス(例:/app)にデプロイしてもリンクが壊れないことです。href="/users"と直書きするとコンテキストパスが抜けて404になりますが、@{/users}なら実行時に補完されます。URLパラメータのエンコードも自動で行われます。
主要なth:属性とコード例
th:text と th:utext(テキストの表示)
th:textはタグの中身を式の結果で置き換えます。値はHTMLエスケープされるため、<b>太字</b>という文字列を渡してもタグとしては解釈されず、そのまま文字として表示されます。エスケープせずHTMLとして描画したい場合はth:utextを使います。
<p th:text="${content}"></p> <!-- <b>太字</b> と表示される -->
<p th:utext="${content}"></p> <!-- 太字 として描画される -->
th:utextはXSSの入口です。ユーザー入力を含む値には使わず、管理者が入稿したリッチテキストなど、サニタイズ済みと保証できる値に限定してください。判断に迷うならth:textを選ぶのが安全側です。
th:if / th:unless / th:switch(条件分岐)
th:ifは条件が真のときだけタグごと出力します。th:unlessはその逆です。注意点は真偽の判定で、Thymeleafはnull・0・空文字・文字列"false"や"off"・"no"をfalseとして扱い、それ以外の非null値をtrueとします。数値の0がfalseになるため、件数チェックで意図しない分岐が起きることがあります。
<p th:if="${user.admin}">管理者メニュー</p>
<p th:unless="${user.admin}">一般ユーザーメニュー</p>
3分岐以上になるならth:switchとth:caseのほうが読めます。どれにも当たらない場合はth:case="*"が拾います。
<div th:switch="${user.role}">
<p th:case="'ADMIN'">管理者</p>
<p th:case="'STAFF'">スタッフ</p>
<p th:case="*">一般</p>
</div>
th:each(繰り返し処理)
リストやMapを反復します。第2引数にステータス変数を取ると、index(0始まり)・count(1始まり)・first・last・size・even・oddが使えます。行の色分けや連番の出力はこれで足ります。
<tr th:each="item, stat : ${items}" th:class="${stat.odd} ? 'odd' : 'even'">
<td th:text="${stat.count}">1</td>
<td th:text="${item.name}">商品名</td>
</tr>
th:href / th:src / th:attr / th:classappend(属性の書き換え)
th:hrefとth:srcは、@{}と組み合わせてリンクや静的リソースのパスを生成します。パス変数はカッコで渡します。
<a th:href="@{/users/{id}(id=${user.id})}">詳細</a>
<img th:src="@{/images/logo.png}" alt="ロゴ">
専用属性が用意されていない属性はth:attrで汎用的に指定できますが、可読性が落ちるためth:data-*や個別のth:属性名で書けるならそちらを優先します。クラスは既存のclass属性を消さずに追記できるth:classappendが便利です。
<span class="badge" th:classappend="${item.soldOut} ? 'badge-gray' : 'badge-red'">在庫</span>
th:inline(JavaScriptへ値を渡す)
スクリプト内でサーバー側の値を使いたい場合はth:inline="javascript"を指定し、/*[[...]]*/で囲みます。文字列は自動でクォートとエスケープが付き、オブジェクトはJSONへ直列化されます。コメント記法なので、テンプレートを静的に開いたときはコメント外のダミー値が使われます。
<script th:inline="javascript">
const userName = /*[[${user.name}]]*/ 'ダミー';
</script>
フォームの入力・バインディングとエラー表示
Spring MVCと組み合わせたフォームでは、th:objectでフォームオブジェクトを選択し、各入力欄をth:fieldで結びつけます。th:fieldはid・name・valueの3つをまとめて生成するため、手書きより取り違えが起きません。送信先はth:actionに@{}で書きます。
<form th:action="@{/users}" th:object="${userForm}" method="post">
<input type="text" th:field="*{name}">
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
<button type="submit">登録</button>
</form>
th:errorsは、コントローラのバリデーション(@ValidとBindingResult)で積まれたエラーメッセージを描画します。th:fieldが*{name}で参照するのはth:objectで選択したオブジェクトのプロパティなので、選択変数式*{}を使う点に注意してください(${userForm.name}と書くとバインディングが効きません)。フォーム全体のエラーをまとめて出すならth:errors="*{all}"(*{*}も同義)、エラーの有無だけ見たいなら${#fields.hasAnyErrors()}を使います。
@PostMapping("/users")
public String create(@Valid @ModelAttribute("userForm") UserForm form,
BindingResult result) {
if (result.hasErrors()) {
return "users/new"; // エラー内容が th:errors に渡る
}
// 登録処理
return "redirect:/users";
}
フラグメントによる共通部品化(th:fragment / th:insert / th:replace)
ヘッダーやフッターのように複数画面で使い回す部分は、フラグメントとして切り出します。定義側はth:fragmentに名前を付けるだけです。
<!-- templates/fragments/common.html -->
<div th:fragment="header">
<h2>サイト共通ヘッダー</h2>
</div>
呼び出し側はth:insertかth:replaceを使います。この2つの違いは公式ドキュメントの定義がそのまま答えで、th:insertは「指定したフラグメントを、自身のホストタグの中身として挿入する」、th:replaceは「自身のホストタグをフラグメントで置き換える」です。
<!-- th:insert の場合 -->
<div th:insert="~{fragments/common :: header}"></div>
<!-- 出力: <div><div>サイト共通ヘッダー</div></div> ← divが二重になる -->
<!-- th:replace の場合 -->
<div th:replace="~{fragments/common :: header}"></div>
<!-- 出力: <div>サイト共通ヘッダー</div> ← ホストのdivは消える -->
意図しない入れ子のdivが出てレイアウトが崩れる場合、たいていth:replaceを使うべき箇所でth:insertを使っています。共通部品の差し込みはth:replaceが既定の選択と考えて構いません。なお、かつて存在したth:includeは3.0の時点で公式に「使用は推奨されない」とされ、3.1で正式にdeprecated化されて使用時に非推奨警告が出るようになりました。3.1のチュートリアルからは記載自体が消えており、将来のバージョンで削除される予定です。新規のコードでは使わず、既存コードはth:insertかth:replaceへ置き換えてください。
フラグメントには引数も渡せます。同じ部品を画面ごとに違うタイトルで使い回せます。
<!-- 定義側 -->
<div th:fragment="card(title, body)">
<h3 th:text="${title}"></h3>
<p th:text="${body}"></p>
</div>
<!-- 呼び出し側 -->
<div th:replace="~{fragments/common :: card('お知らせ', ${notice})}"></div>
Thymeleaf 3.1へ上げたときに踏む落とし穴
入門記事がほとんど触れないまま、実務で最も事故につながるのがここです。Thymeleaf 3.1では、テンプレートから直接使えていたWeb API系の式ユーティリティオブジェクト、すなわち#request・#response・#session・#servletContextが削除されました。リクエストパラメータなど安全でない値へテンプレートから直接触れる経路を塞ぐというセキュリティ上の理由と、コア部分をjavax.*のServlet APIから切り離してjakarta.*やその他のWeb技術に対応させるという設計上の理由の両方によるものです(thymeleaf/thymeleaf issue #886)。
Spring Boot 3以降はThymeleaf 3.1系を引き込むため、Spring Boot 2からアップグレードした既存プロジェクトで、テンプレート中の${#request...}や${#session.user}が実行時に例外(「これらの式ユーティリティオブジェクトはテンプレート式で既定では利用できず、使用は推奨されない」旨のIllegalArgumentException)を投げます。コンパイルは通り、その画面を開いた瞬間に初めて落ちるため、テストで踏んでいない画面から順に本番で表面化します。
対処はテンプレート側で辻褄を合わせるのではなく、コントローラ側で必要な値だけをModelに詰めることです。セッションの値ならコントローラで@SessionAttributeやHttpSessionから取り出してModelに移し、テンプレートは${loginUser}のように通常の変数として受け取ります。どうしてもオブジェクトそのものが必要な場合に限り、コンテキスト変数として明示的に追加する形になります。
// NG: テンプレート側で ${#session.user} を参照する(3.1では例外)
// OK: コントローラで詰め替える
@GetMapping("/mypage")
public String mypage(HttpSession session, Model model) {
model.addAttribute("loginUser", session.getAttribute("user"));
return "mypage";
}
アップグレード前に#request・#session・#response・#servletContextに加え、thymeleaf-spring側が提供していた#httpServletRequest・#httpSessionもテンプレート全体でgrepし、該当箇所を洗い出しておくのが最短です。ログイン中ユーザーの表示をセッションの直参照で書いている画面は要注意で、共通ヘッダーのフラグメントに書かれていると全画面が落ちます。Spring Securityと組み合わせている場合は、認証情報の参照に使うアーティファクトの世代も確認してください。Spring Security 6系ならthymeleaf-extras-springsecurity6、Spring Boot 4が採用するSpring Security 7系なら対応する7系のアーティファクトに揃える必要があります。基礎はSpring Securityとは何か?その基本と重要性で整理しています。
JSPや他のテンプレートエンジンとの比較
| 項目 | Thymeleaf | JSP | FreeMarker |
|---|---|---|---|
| 記法 | HTML属性(th:) | 独自タグ・スクリプトレット | 独自記法(${}, <#if>) |
| 静的表示 | そのまま開ける | 崩れる | 崩れる |
| Spring Boot対応 | 公式スターターあり | JARで制約あり | 公式スターターあり |
| 主な用途 | 画面付きWebアプリ | 既存資産の保守 | メール・帳票にも強い |
新規のSpring BootアプリでサーバーサイドレンダリングをするならThymeleafを選んで問題ありません。JSPはSpring Bootの実行可能JAR(組み込みTomcat)では公式に制限があり、WARでのデプロイが前提になるため、新規採用の理由が乏しくなっています。
一方で、Thymeleafを選ぶべきでない場面もはっきりしています。画面をReactやVueで作り、サーバーはJSONを返すAPIに徹する構成なら、Thymeleafの依存は不要です。この構成でThymeleafを残すと、HTMLを返す経路とJSONを返す経路が二重に存在し、認証・エラー処理の実装が分岐して複雑になるだけです。「Spring Bootだからとりあえず入れる」のではなく、サーバーがHTMLを組み立てて返すかどうかで判断してください。メールのHTML本文や帳票のように画面以外のテンプレートが主目的なら、Thymeleafでも生成できますが(TemplateEngineを直接使う)、FreeMarkerのほうが素直なこともあります。
よくある質問(FAQ)
Thymeleafの読み方は何ですか?
「タイムリーフ」です。ハーブのthyme(タイム)とleafを合わせた名前で、thのhは発音しません。「シームリーフ」と読まれることがありますが誤りです。
${…} と *{…} の違いは何ですか?
${...}はコンテキスト変数(Modelに入れた値)全体を対象に評価します。*{...}はth:objectで選択したオブジェクトを起点に評価します。th:object="${userForm}"を指定したブロック内では*{name}と${userForm.name}が同じ値になりますが、フォームのth:fieldではバインディングの都合上*{}を使う必要があります。
th:insert と th:replace はどちらを使えばよいですか?
共通部品の差し込みはth:replaceが基本です。th:insertはホストタグを残したままその中身としてフラグメントを挿入するため、divが二重になります。th:replaceはホストタグごとフラグメントに置き換わります。かつてのth:includeは3.0で「推奨されない」とされ、3.1で正式にdeprecated化(使用時に警告)、チュートリアルからも記載が消えています。
Spring Boot 3に上げたらテンプレートがエラーになりました。原因は?
Thymeleaf 3.1で#request・#session・#response・#servletContextが削除されたことが原因です。テンプレート内でこれらを参照している箇所をコントローラでModelに詰め替える形へ書き換えてください。詳細は本記事の「Thymeleaf 3.1へ上げたときに踏む落とし穴」で解説しています。
テンプレートを修正しても画面に反映されないのはなぜですか?
テンプレートキャッシュが有効になっています。開発時はapplication.propertiesにspring.thymeleaf.cache=falseを指定するか、spring-boot-devtoolsを依存に追加してください。本番環境ではキャッシュを有効(既定)に戻します。