【保存版】Spring Bootで共通ヘッダー・フッターを一括管理:Thymeleafフラグメント&レイアウト設計ガイド

Spring Boot(Javaフレームワーク)

Spring Boot × Thymeleafで「共通ヘッダー」「共通フッター」「共通CSS/JS」を1か所管理にする最短ルート。
th:fragment / th:replace を軸に、実務でもすぐ使えるディレクトリ構成・サンプルHTML・Maven/Gradle設定・FAQまで丁寧に解説します。

なぜ共通化が必要?(メリット3つ)

  1. 保守性UP:ヘッダーのリンク1つ直すだけで、全ページ一括反映。
  2. 表示品質の統一:UIのブレを排除して、ブランド/UXを守る。
  3. 開発速度UP:新規ページは「中身」だけ作ればOK。

メンター:まずは置き場所のルールを決めるのが成功のコツ!

プロジェクト構成(推奨ディレクトリ)

src/
 └─ main/
    ├─ java/…(省略)
    └─ resources/
       ├─ static/
       │   ├─ css/
       │   │   └─ app.css
       │   └─ js/
       │       └─ app.js
       └─ templates/
           ├─ _fragments/
           │   ├─ header.html
           │   └─ footer.html
           ├─ _layout/
           │   └─ base.html
           └─ page/
               └─ home.html

まーくん:「_fragments」「_layout」で役割が見える化されるね!

Thymeleafフラグメントの作り方

th:fragment で「この要素はパーツです」と宣言します。

header.html(共通ヘッダー)

<!-- templates/_fragments/header.html -->
<header th:fragment="siteHeader(title)" class="site-header">
  <div class="container">
    <a href="/" class="logo">MyApp</a>
    <nav class="nav">
      <a th:classappend="${active=='home'} ? 'active'" th:href="@{/}">Home</a>
      <a th:classappend="${active=='products'} ? 'active'" th:href="@{/products}">Products</a>
      <a th:classappend="${active=='about'} ? 'active'" th:href="@{/about}">About</a>
    </nav>
    <button class="nav-toggle" aria-label="メニュー">☰</button>
  </div>
</header>

footer.html(共通フッター)

<!-- templates/_fragments/footer.html -->
<footer th:fragment="siteFooter" class="site-footer">
  <div class="container">
    <p>© <span th:text="${#calendars.format(#calendars.createNow(), 'yyyy')}">2025</span> MyApp. All rights reserved.</p>
  </div>
</footer>

メンター:フラグメントは引数を受け取れる。例:siteHeader(title)

レイアウト(ベースHTML)と差し込み

全ページの骨組みを _layout/base.html にまとめ、各ページは中身だけに集中。

base.html(ベース)

<!-- templates/_layout/base.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 th:text="${title} ?: 'MyApp'">MyApp</title>
  <link rel="stylesheet" th:href="@{/css/app.css(v=${appVersion})}">
</head>
<body>
  <div th:replace="~{_fragments/header :: siteHeader(${title})}"></div>

  <main class="container" th:fragment="content">
    <!-- 子ページがここを差し替える -->
    <div th:replace="${content}"></div>
  </main>

  <div th:replace="~{_fragments/footer :: siteFooter}"></div>
  <script th:src="@{/js/app.js(v=${appVersion})}"></script>
</body>
</html>

home.html(ページ本体)

<!-- templates/page/home.html -->
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:replace="~{_layout/base :: content(~{::section})}" th:with="title='ホーム', active='home'">
  <section>
    <h1>Welcome</h1>
    <p>これはトップページの本文です。</p>
  </section>
</div>
</body>
</html>

まーくんbase :: content(~{::section}) の書き方、パッと理解できた!

共通CSS/JSとキャッシュ対策

src/main/resources/static/ 配下に置くと、/css/app.css のように配信されます。
キャッシュ更新にはクエリ ?v=${appVersion} を付与。

app.css(レスポンシブ+ダークモード配慮)

:root { --bg:#0b1220; --text:#e5e7eb; --muted:#94a3b8; --brand:#22d3ee; --card:#0f172a; }
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:16px/1.8 system-ui}
.container{max-width:1100px;margin:auto;padding:16px}
.site-header,.site-footer{background:#0c1526;border-bottom:1px solid #17223a}
.logo{font-weight:700;color:#e2e8f0;text-decoration:none}
.nav{display:flex;gap:16px}
.nav a{color:#cbd5e1;text-decoration:none;padding:8px 10px;border-radius:10px}
.nav a.active{outline:2px solid var(--brand)}
.nav-toggle{display:none}
@media (max-width:768px){
  .nav{display:none}
  .nav.open{display:flex;flex-direction:column;margin-top:8px}
  .nav-toggle{display:inline-block;background:#0f172a;color:#e2e8f0;border:1px solid #1f2a44;border-radius:10px;padding:8px 12px}
}
.lead{color:var(--muted)}
.toc{background:var(--card);border:1px solid #1f2a44;border-radius:16px;padding:12px;margin:18px 0}
.eyecatch svg{width:100%;height:auto;border-radius:24px;display:block}
.balloon{display:flex;gap:12px;align-items:flex-start;margin:16px 0}
.balloon .avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#22d3ee,#a78bfa)}
.balloon p{background:#0f172a;border:1px solid #1f2a44;border-radius:14px;padding:10px 12px;margin:0;max-width:720px}
code{background:#0f172a;border:1px solid #1f2a44;border-radius:6px;padding:2px 6px}
pre{background:#0b1220;border:1px solid #1f2a44;border-radius:12px;padding:14px;overflow:auto}

app.js(モバイルナビ)

document.addEventListener('click', (e) => {
  const btn = e.target.closest('.nav-toggle');
  if (!btn) return;
  const nav = document.querySelector('.nav');
  nav?.classList.toggle('open');
});

バージョン付与(キャッシュバスティング)

アプリ起動時にバージョンをModelに入れておくと便利です。

// 例:起動時にバージョン文字列を用意しておく
@Bean
public String appVersion() { return String.valueOf(System.currentTimeMillis()); }

もしくは @ControllerAdvice で全コントローラへ配布(後述)。

全ページ共通のModel属性を配る(サイト名/ナビ状態/バージョンなど)

@ControllerAdvice
public class GlobalModelAdvice {

  @ModelAttribute("siteName")
  public String siteName() {
    return "MyApp";
  }

  @ModelAttribute("appVersion")
  public String appVersion() {
    return String.valueOf(System.currentTimeMillis());
  }
}

アクティブメニューの指定は各ページ側で active を渡せばOK。

メンター@ControllerAdvice を使うと「どのページでも必要な定数」を安全に共有できる。

(発展)Thymeleaf Layout Dialectを使う方法

標準機能でも十分ですが、Thymeleaf Layout Dialect を使うと記述がさらに素直になります。

依存関係(どちらか)

<!-- Maven -->
<dependency>
  <groupId>nz.net.ultraq.thymeleaf</groupId>
  <artifactId>thymeleaf-layout-dialect</artifactId>
  <version>3.3.0</version>
</dependency>
// Gradle
implementation "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0"

使い方(レイアウト適用)

<!-- 子ページ -->
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head><title>ページタイトル</title></head>
<body layout:decorate="~{_layout/base}">
  <section layout:fragment="content">
    <h1>Hello</h1>
  </section>
</body>
</html>

まーくんlayout:decoratelayout:fragment、語感で覚えやすい!

よくある質問(FAQ)

Q1. th:include と th:replace の違いは?

th:replace対象要素ごと置換th:include内部の子要素を挿入。ベース側の不要なラッパーを残したくない時は th:replace が明快。

Q2. CSSやJSの読み込み順は?

基本は CSSは<head>、JSは<body>の末尾。ページ固有JSはページ側で読みたい場合、th:fragmentで「scripts」領域を用意して差し込みましょう。

Q3. ページごとにタイトルやメタ説明を変えたい

<div th:replace="~{_layout/base :: content(~{::section})}" 
     th:with="title=${pageTitle}, metaDesc=${pageDesc}, active='about'">
  <section>...</section>
</div>

Q4. 静的ファイルのキャッシュが強すぎる

バージョンパラメータを付与(例:/css/app.css?v=${appVersion})。本番はCDNやETag/Cache-Controlの設定も検討。

Q5. 国際化(i18n)は?

messages_xx.properties を用意し、#{key} で参照。ヘッダー内リンク文言も外出し可能。

デプロイ前チェックリスト

  • 全ページでヘッダー/フッターが崩れていないか(スマホ幅含む)。
  • 現在ページのナビに .active が付くか。
  • CSS/JSにバージョン付与(キャッシュ切り替わるか)。
  • タイトル/メタ説明がページごとに適切か。
  • FCP/CLSが悪化していないか(Lighthouseで確認)。

本記事は、実務/個人開発で再利用しやすい構成を想定しています。必要に応じて命名や階層を調整してください。

コメント

タイトルとURLをコピーしました