Thymeleaf-Templates für den Flurmonitor: Ausdrücke, Schleifen, Bedingungen, Fragmente und Bootstrap als WebJar
https://bildung.social/@oerinformatik/
https://oer-informatik.de/flurmonitor-thymeleaf-templates
Thymeleaf, Template, Natural Templating, th:text, th:utext, th:each, th:if, th:classappend, Fragmente, Cross-Site-Scripting, WebJar, Bootstrap
tl/dr; (ca. 29 min Lesezeit): Aus der statischen Abfahrtsanzeige wird ein echtes Template:
th:eacherzeugt für jede offene Fahrt eine Tabellenzeile,th:ifzeigt einen Satz statt einer leeren Tabelle,th:classappendhebt die nächste Abfahrt hervor. Der Artikel unterscheidet die Ausdrucksarten${…},*{…},@{…}und#{…}, zeigt, warumth:textvor eingeschleustem HTML schützt undth:utextnicht, lagert Kopf, Navigation, Fußzeile und Tabelle in Fragmente aus und holt Bootstrap als WebJar aus dem eigenen Server statt vom CDN [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über den Flurmonitor mit Thymeleaf: Projektübersicht / Scrum-Projektstart / Thymeleaf einbinden / Templates / Ansichtsmodell / Integrationstest der Web-Schicht / Konfiguration und Formatierung / Aktualisierung / Hinweise mit Formularen / Robustheit und Betrieb / GUI-Test mit Selenium / Akzeptanztest / Change Request Keycloak
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste der Artikel Thymeleaf ins Backend einbinden und Bootstrap-Grundlagen auf.
Als Thymeleaf-Template-Grundlagen kann ich nach Durchlesen des Artikels…
… erklären, was ein Natural Template ist und warum dieselbe Datei im Browser als Entwurf und auf dem Server als Vorlage funktioniert.
… die Ausdrucksarten
${…},*{…},@{…}und#{…}unterscheiden und für eine Aufgabe die passende wählen.… Werte mit
th:textausgeben und begründen, warumth:utextbei Daten aus Benutzereingaben eine Cross-Site-Scripting-Lücke öffnet.… mit
th:eacheine Tabellenzeile je Listeneintrag erzeugen und die Statusvariable (index,count,first,last,odd) nutzen.… mit
th:ifundth:unlessTeile der Seite abhängig von Daten ein- oder ausblenden, etwa bei einer leeren Liste.
Darüber hinaus kann ich als Thymeleaf-Template-Expertenwissen…
… mit
th:classappendCSS-Klassen abhängig von Bedingungen ergänzen.… wiederkehrende Seitenteile als Fragmente mit Parametern (
th:fragment) auslagern und mitth:replaceoderth:inserteinbinden.… Bootstrap als WebJar einbinden, die URL unter
/webjars/angeben und die Vor- und Nachteile gegenüber CDN und npm abwägen.
Im letzten Artikel hat das Team Thymeleaf ins Backend des Mitfahrlabors eingebunden. Unter /flurmonitor liefert der Server jetzt eine Seite, auf der zwei Werte aus dem Model stehen: der Standort und die Zahl der offenen Fahrten. Die Tabellen darunter zeigen aber noch die Beispielfahrten der statischen Seite. In diesem Artikel wird aus der Seite ein echtes Template: Die Zeilen entstehen aus den Daten, die der Controller ins Model legt.
Das Bild zeigt das Prinzip, um das es in diesem Artikel geht. Thymeleaf liest ein Template mit th:-Attributen und die Daten aus dem Model und setzt beides zu HTML zusammen, in dem kein einziges th:-Attribut mehr steht:
Stand im Projekt: Sprint 1, die zweite Aufgabe von MON-01
Sprint 1 läuft. Im Daily Scrum hat das Team gemeldet, dass die erste Aufgabe von MON-01 im Review liegt. Die nächste Aufgabe im Sprint Backlog lautet „statische Abfahrtsanzeige in ein Template mit th:each und th:text umbauen“. Die Developers zerlegen sie wie beim letzten Mal in Schritte, die jeweils an einem Tag fertig werden:
| Nr. | Aufgabe im Sprint Backlog | fertig, wenn … | Abschnitt |
|---|---|---|---|
| T7 | offene Fahrten nach Richtung getrennt ins Model legen | Model enthält zurSchule und nachHause ohne Namen |
Daten ins Model |
| T8 | Tabellenzeilen mit th:each aus dem Model erzeugen |
Seite zeigt die Fahrten aus data.sql statt der Beispielzeilen |
th:each |
| T9 | leere Liste abfangen (AK-MON-01.4) | statt einer leeren Tabelle steht „Gerade keine offenen Fahrten – schau in die App.“ | th:if |
| T10 | Kopf, Navigation, Fußzeile und Tabelle als Fragmente | jede Tabelle und jeder Rahmenteil steht nur einmal im Code | Fragmente |
| T11 | Bootstrap als WebJar statt vom CDN | Seite lädt keine Datei mehr von fremden Servern | WebJar |
| T12 | Smoke-Test erweitern, Pipeline grün | Test prüft Zeilen, leere Liste, fehlende Namen und WebJar | Smoke-Test |
Zwei Abnahmekriterien von MON-01 lassen sich mit diesen Aufgaben schon prüfen: AK-MON-01.3 (kein Name, kein Pseudonym im HTML) und AK-MON-01.4 (Satz statt leerer Tabelle). Das Zeitfenster bis zum Ende des nächsten Schultags und die Sortierung (MON-03) folgen erst mit dem Ansichtsmodell im nächsten Artikel. Bis dahin zeigt die Seite alle offenen Fahrten ab heute, wie der MonitorService sie liefert.
Natural Templates: eine Datei für Entwurf und Server
Viele Template-Engines mischen HTML mit eigenen Zeichen wie {{ name }} oder <% … %>. Öffnet man so eine Datei im Browser, sieht man die Platzhalter als Text, und ein HTML-Validator meldet Fehler. Thymeleaf geht einen anderen Weg: Seine Anweisungen stehen in zusätzlichen Attributen mit dem Präfix th:. Ein Browser kennt diese Attribute nicht und ignoriert sie einfach. Das Template bleibt eine HTML-Datei, die sich ohne Server öffnen lässt und dann die Beispielwerte zeigt. Thymeleaf nennt das Natural Templating, die Dateien heißen Natural Templates.
<p class="lead mb-0">Flurmonitor im <span th:text="${standort}">Eingang A</span> des DBH-Berufskollegs</p>Im Browser ohne Server steht dort „Eingang A“. Auf dem Server ersetzt Thymeleaf den Inhalt des span durch den Wert des Model-Attributs standort und entfernt das Attribut th:text. Für das Team hat das einen praktischen Nutzen: Die Gestaltung kann an derselben Datei weiterarbeiten, die der Server verwendet, ohne dass jemand das Backend starten muss.
Ganz ohne Preis ist das nicht. Je mehr der Server zusammensetzt, etwa aus Fragmenten in anderen Dateien, desto weniger zeigt die Datei ohne Server. Thymeleaf erlaubt dafür Platzhalter-Inhalte, die nur in der Entwurfsansicht stehen und beim Rendern verschwinden; dazu später mehr bei th:remove.
Vier Arten von Ausdrücken
In den th:-Attributen stehen Ausdrücke. Thymeleaf unterscheidet sie am Zeichen vor der geschweiften Klammer. Die vier wichtigsten kommen alle im Flurmonitor vor:
| Ausdruck | Name | liefert | Beispiel im Flurmonitor |
|---|---|---|---|
${…} |
Variablenausdruck | einen Wert aus dem Model (oder eine lokale Variable) | th:text="${standort}" |
*{…} |
Auswahlausdruck | eine Eigenschaft des Objekts, das th:object ausgewählt hat |
th:text="*{ort}" in einer Tabellenzeile |
@{…} |
Link-Ausdruck | eine URL, bei / am Anfang mit dem Kontextpfad der Anwendung |
th:href="@{/css/flurmonitor-bootstrap.css}" |
#{…} |
Nachrichtenausdruck | einen Text aus messages.properties |
th:text="#{flurmonitor.keine-fahrten}" |
Ein fünfter Ausdruck, der Fragmentausdruck ~{…}, verweist auf ein Stück eines anderen Templates; er folgt im Abschnitt über Fragmente.
Innerhalb von ${…} und *{…} arbeitet in einer Spring-Anwendung die Spring Expression Language (SpEL). Mit ihr kannst Du Eigenschaften über ihren Namen ansprechen (${eintrag.ort} ruft getOrt() auf), Methoden aufrufen (${eintraege.isEmpty()}), rechnen und vergleichen (*{plaetze == 1}) und mit ? : zwischen zwei Werten wählen. Texte setzt Du mit + zusammen oder bequemer mit einer Literal-Ersetzung zwischen senkrechten Strichen: th:text="|Offene Fahrtangebote: ${ueberschrift}|".
Ein Template sollte dabei nicht mehr Logik enthalten, als für die Darstellung nötig ist. Was eine Fahrt „offen“ macht oder in welcher Reihenfolge sie erscheint, ist Fachlogik und gehört in den Service bzw. in das Ansichtsmodell, das der nächste Artikel baut.
Die Fahrten ins Model legen
Bisher hat der Controller nur die Zahl der offenen Fahrten ins Model gelegt. Jetzt kommen die Fahrten selbst dazu, getrennt nach Richtung, denn die Seite zeigt zwei Tabellen. Für den einzelnen Eintrag gibt es schon einen passenden Typ: MonitorEintrag aus dem API-Vertrag. Ihn liefert auch die REST-Schnittstelle /v1/monitor/eintraege, und er enthält nach Vertrag weder Namen noch Pseudonym. Der MonitorMapper aus dem Backend wandelt jedes Angebot in einen solchen Eintrag um:
@GetMapping("/flurmonitor")
public String anzeigen(Model model) {
// anonyme Einträge wie in der REST-Schnittstelle: ohne Namen und ohne Pseudonym (AK-MON-01.3)
List<MonitorEintrag> eintraege = monitorService.naechsteAngebote(null, null, null, HOECHSTENS).stream()
.map(MonitorMapper::zuEintrag)
.toList();
model.addAttribute("standort", STANDORT);
model.addAttribute("anzahlOffen", eintraege.size());
model.addAttribute("zurSchule", inRichtung(eintraege, Richtung.ZUR_SCHULE));
model.addAttribute("nachHause", inRichtung(eintraege, Richtung.NACH_HAUSE));
return "flurmonitor"; // View-Name: templates/flurmonitor.html
}
private static List<MonitorEintrag> inRichtung(List<MonitorEintrag> eintraege, Richtung richtung) {
return eintraege.stream()
.filter(e -> e.getRichtung() == richtung)
.toList();
}Warum nicht gleich die Entity Angebot ins Model legen? Technisch ginge das: Thymeleaf kann jede Eigenschaft eines Objekts lesen, auch angebot.fahrer.vorname. Genau das ist das Problem. Steht ein Objekt mit Namen im Model, genügt ein unbedachter Ausdruck im Template, und der Name hängt im Foyer. Was nicht im Model steht, kann kein Template ausgeben. Der MonitorEintrag ist deshalb eine Übergangslösung mit der richtigen Eigenschaft; im nächsten Artikel ersetzt ihn ein eigenes Ansichtsmodell, das zusätzlich Zeitfenster, Sortierung und „voll“ kennt.
Am Dienstag, 20.10.2026, um 06:00 Uhr sind laut data.sql zwei Angebote offen, beide zur Schule: Herford um 06:50 Uhr und Bad Salzuflen am 22.10. um 06:45 Uhr. Die Liste nachHause ist leer. Beide Fälle braucht das Template.
Werte ausgeben: th:text und th:utext
th:text ersetzt den Inhalt eines Elements durch den Wert eines Ausdrucks. Dabei maskiert (escaped) Thymeleaf alle Zeichen, die in HTML eine Bedeutung haben: < wird zu <, > zu >, & zu &, " zu " und ' zu '. Im Flurmonitor sieht man das am Treffpunkt in Herford. In der Datenbank steht Park & Ride am Bahnhof, im gerenderten HTML Park & Ride am Bahnhof, und der Browser zeigt wieder das kaufmännische Und.
th:utext (unescaped text) gibt den Wert dagegen ungeprüft als HTML aus. Das ist nur dann richtig, wenn der Wert selbst HTML sein soll und aus einer vertrauenswürdigen Quelle stammt, etwa ein fest im Code hinterlegter Text mit <strong>. Kommt der Wert aus einer Eingabe von Benutzer*innen, ist th:utext eine Sicherheitslücke.
Ein Beispiel: Angenommen, die Treffpunkte könnten in der App frei eingegeben werden, und jemand trägt als Treffpunkt <img src="x" onerror="location='https://example.org'"> ein. Mit th:text steht auf dem Monitor genau diese Zeichenfolge als Text, harmlos und gut sichtbar. Mit th:utext landet sie als HTML-Element in der Seite. Das Bild lädt nicht, der Browser führt deshalb den JavaScript-Code in onerror aus, und der Monitor im Foyer zeigt eine fremde Seite. Diese Angriffsart heißt Cross-Site-Scripting (XSS): Ein Angreifer schleust Skriptcode in eine Seite ein, die andere aufrufen. Weil der Code hier gespeichert wird und bei jedem Aufruf erneut wirkt, spricht man von gespeichertem (stored) XSS.
th:text |
th:utext |
|
|---|---|---|
| Sonderzeichen | werden maskiert (< → <) |
bleiben unverändert |
Wert <b>bitte</b> erscheint als |
Text mit spitzen Klammern | fett gedrucktes „bitte“ |
| für Daten aus Eingaben | sicher, Standard | XSS-Lücke |
| sinnvoll für | fast alles | fest hinterlegtes, vertrauenswürdiges HTML |
Die Regel für das Team ist deshalb einfach: Im Flurmonitor steht überall th:text. Wer Formatierung braucht, löst sie mit HTML-Elementen und CSS-Klassen im Template, nicht mit HTML in den Daten. Eine zusätzliche Schutzschicht, die Spring Security setzen kann, ist der HTTP-Header Content-Security-Policy: Er verbietet dem Browser zum Beispiel Inline-Skripte. Er ersetzt das Maskieren aber nicht, sondern fängt Fehler zusätzlich ab.
Zeilen wiederholen: th:each und die Statusvariable
th:each wiederholt das Element, an dem es steht, einmal für jeden Eintrag einer Liste. Steht es an einem <tr>, entsteht eine Tabellenzeile je Fahrt:
<tr th:each="eintrag, status : ${eintraege}" th:object="${eintrag}"
th:classappend="${status.first} ? 'naechste-abfahrt'">
<td><time th:datetime="*{datum}" th:text="*{datum}" datetime="2026-11-16">2026-11-16</time></td>
<td><time th:datetime="*{zeit}" th:text="*{zeit}" datetime="06:50">06:50</time></td>
<td th:text="*{ort}">Gütersloh</td>
<td th:text="*{treffpunkt}">Hauptbahnhof, ZOB Steig 3</td>
<td class="text-end">
<span class="badge" th:classappend="*{plaetze == 1} ? 'text-bg-warning' : 'text-bg-primary'"><span
th:text="*{plaetze}">2</span><span th:if="*{plaetze == 1}"> (letzter)</span></span>
</td>
</tr>Der Ausdruck eintrag, status : ${eintraege} liest sich so: „Für jeden Eintrag der Liste eintraege, der jeweils eintrag heißt, mit der Statusvariablen status.“ Die Variable eintrag gilt nur innerhalb des wiederholten Elements. th:object="${eintrag}" wählt ihn als aktuelles Objekt aus; darum genügt in den Zellen der kurze Auswahlausdruck *{ort} statt ${eintrag.ort}. Beide Schreibweisen liefern denselben Wert. th:datetime setzt das Attribut datetime des time-Elements; für fast jedes HTML-Attribut gibt es so ein Gegenstück mit th:.
Die Statusvariable verrät, an welcher Stelle der Schleife Thymeleaf gerade ist. Lässt Du sie weg, legt Thymeleaf trotzdem eine an: Sie heißt dann wie die Laufvariable mit der Endung Stat, hier also eintragStat.
| Eigenschaft | Bedeutung | Werte bei zwei Fahrten |
|---|---|---|
index |
Position, beginnt bei 0 | 0, 1 |
count |
Position, beginnt bei 1 | 1, 2 |
size |
Anzahl der Einträge der Liste | 2, 2 |
current |
der aktuelle Eintrag selbst | Herford, Bad Salzuflen |
first, last |
erster bzw. letzter Durchlauf | true/false, false/true |
odd, even |
ungerader bzw. gerader Durchlauf, gezählt ab 1 | odd beim ersten, even beim zweiten |
Im Flurmonitor nutzt das Team status.first, um die nächste Abfahrt fett hervorzuheben. Für Streifen in der Tabelle braucht es odd und even nicht, das erledigt schon die Bootstrap-Klasse table-striped.
th:each funktioniert nicht nur mit Listen, sondern mit allem, was sich durchlaufen lässt: Arrays, Mengen (Set) und Maps. Bei einer Map ist jeder Eintrag ein Paar mit key und value.
Bedingungen: th:if, th:unless und th:classappend
Am 20.10.2026 ist die Liste nachHause leer. Eine Tabelle, die nur aus Kopfzeile besteht, sieht auf einem Monitor wie ein Fehler aus. AK-MON-01.4 verlangt stattdessen den Satz „Gerade keine offenen Fahrten – schau in die App.“. Dafür gibt es zwei Attribute:
<p th:if="${eintraege.isEmpty()}" class="fs-4" th:text="#{flurmonitor.keine-fahrten}">Gerade keine offenen Fahrten – schau in die App.</p>
<div th:unless="${eintraege.isEmpty()}" class="table-responsive">
<table class="table table-striped align-middle caption-top abfahrten">
…
</table>
</div>th:if rendert das Element samt Inhalt nur, wenn die Bedingung zutrifft; sonst fehlt es im HTML ganz. th:unless ist das Gegenstück und rendert das Element nur, wenn die Bedingung nicht zutrifft. Mit derselben Bedingung an beiden Stellen erscheint immer genau eins von beiden.
Was als „wahr“ gilt, regelt Thymeleaf großzügiger als Java. Wahr ist ein Wert, wenn er nicht null ist und nicht zu den Ausnahmen gehört: Ein Boolean muss true sein, eine Zahl ungleich 0, und ein Text darf nicht "false", "off" oder "no" lauten. Jedes andere Objekt ist wahr, auch eine leere Liste. Deshalb steht in der Bedingung ausdrücklich ${eintraege.isEmpty()}. Thymeleaf bringt für solche Prüfungen auch Hilfsobjekte mit, die mit # beginnen, etwa ${#lists.isEmpty(eintraege)}; beide Schreibweisen sind gleichwertig.
Manchmal soll ein Element nicht verschwinden, sondern nur anders aussehen. Dafür ist th:classappend da: Es hängt eine Klasse an die Klassen an, die schon im class-Attribut stehen. In der Tabelle oben bekommt das Badge mit den freien Plätzen die Bootstrap-Klasse text-bg-warning, wenn nur noch ein Platz frei ist, sonst text-bg-primary. Das ist der erste Schritt zu MON-04 („Fahrten mit nur noch einem freien Platz hervorheben“). Ein Ausdruck der Form ${bedingung} ? 'klasse' ohne Teil hinter dem Doppelpunkt liefert null, wenn die Bedingung nicht zutrifft; dann hängt Thymeleaf nichts an. So bekommt nur die erste Zeile die Klasse naechste-abfahrt, für die das Stylesheet einen fetten Text festlegt:
/* 6. Thymeleaf-Templates: die nächste Abfahrt jeder Tabelle fett (Klasse per th:classappend) */
.abfahrten tr.naechste-abfahrt > td {
font-weight: bold;
}Würdest Du statt th:classappend das Attribut th:class verwenden, ersetzte Thymeleaf das ganze class-Attribut, und Klassen wie badge gingen verloren.
Texte aus messages.properties
Den Satz für die leere Liste schreibt das Template nicht selbst hin, sondern holt ihn mit dem Nachrichtenausdruck #{flurmonitor.keine-fahrten} aus der Datei src/main/resources/messages.properties:
# Texte der Oberfläche; Thymeleaf liest sie mit #{schluessel}
flurmonitor.keine-fahrten=Gerade keine offenen Fahrten – schau in die App.
Liegt diese Datei im Klassenpfad, richtet Spring Boot automatisch eine MessageSource ein, und Thymeleaf schlägt Schlüssel darin nach. Der Text steht dann an einer Stelle, auch wenn ihn zwei Tabellen und später die Abnahmetests brauchen. Dass der Satz im Template zusätzlich als Beispielinhalt steht, dient wieder der Ansicht ohne Server. Wie sich über diesen Weg weitere Sprachen anbinden lassen und wie Datum und Uhrzeit lesbar formatiert werden, zeigt der Artikel zu Konfiguration und Formatierung. Bis dahin erscheint das Datum in der Form 2026-10-20.
Fragmente: Kopf, Navigation und Fußzeile nur einmal schreiben
Der Flurmonitor bekommt im Lauf der Reihe weitere Seiten, etwa für die Mitfahrgesuche und die Hinweise der Schulverwaltung. Alle brauchen denselben Kopf mit Bootstrap und Stylesheet, dieselbe Navigation und dieselbe Fußzeile. Kopiert man diese Teile in jede Seite, muss man jede Änderung an mehreren Stellen nachziehen. Thymeleaf löst das mit Fragmenten: Ein Element mit th:fragment="name" wird zu einem benannten Baustein, den andere Templates einbinden.
Das Team legt dafür die Datei templates/fragmente/rahmen.html an. Sie ist selbst ein vollständiges HTML-Dokument, damit sie sich ohne Server öffnen lässt, und enthält vier Fragmente. Gekürzt:
<head th:fragment="kopf(titel)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${titel}">Mitfahrlabor</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">
<link rel="stylesheet" href="../../static/css/flurmonitor-bootstrap.css" th:href="@{/css/flurmonitor-bootstrap.css}">
</head>
<body>
<nav th:fragment="navigation" class="navbar navbar-expand-md bg-primary" data-bs-theme="dark" aria-label="Hauptnavigation">
…
<li class="nav-item"><a class="nav-link" href="#zur-schule" th:href="@{/flurmonitor#zur-schule}">Zur Schule</a></li>
…
</nav>
<footer th:fragment="fusszeile" class="container-fluid px-4 py-3 border-top border-2 border-primary small">
…
</footer>
<script th:fragment="skripte" src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script>
</body>Das Fragment kopf hat einen Parameter titel. Innerhalb des Fragments ist er wie eine Variable mit ${titel} erreichbar, sodass jede Seite ihren eigenen Titel bekommt. Die Links der Navigation zeigen mit @{/flurmonitor#zur-schule} ausdrücklich auf die Abfahrtsseite, weil die Navigation künftig auch auf anderen Seiten steht.
Eingebunden werden die Fragmente in flurmonitor.html mit th:replace und einem Fragmentausdruck ~{datei :: fragment}. Die Datei gibst Du wie einen View-Namen an, also ohne templates/ und ohne .html:
<head th:replace="~{fragmente/rahmen :: kopf('Mitfahrlabor – Abfahrten am Flurmonitor')}">
<!-- nur für die Ansicht ohne Server -->
<meta charset="utf-8">
<title>Mitfahrlabor – Abfahrten am Flurmonitor</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="../static/css/flurmonitor-bootstrap.css">
</head>
<body>
<header>
<nav th:replace="~{fragmente/rahmen :: navigation}" class="navbar bg-primary" data-bs-theme="dark">
<span class="navbar-brand px-4 fs-3 fw-bold">Mitfahrlabor</span>
</nav>
…
</header>
…
<footer th:replace="~{fragmente/rahmen :: fusszeile}"></footer>
<script th:replace="~{fragmente/rahmen :: skripte}"></script>
</body>Beim Rendern ersetzt Thymeleaf jedes dieser Elemente vollständig durch das Fragment. Was zwischen Start- und End-Tag des Platzhalters steht, ist wieder nur Entwurf: Öffnest Du flurmonitor.html direkt im Browser, siehst Du eine vereinfachte Navigation und Bootstrap vom CDN; der Server liefert stattdessen die Fragmente aus rahmen.html.
Neben th:replace gibt es th:insert. Der Unterschied liegt darin, was mit dem Element passiert, an dem das Attribut steht:
| im Template | Ergebnis im HTML |
|---|---|
<footer th:replace="~{fragmente/rahmen :: fusszeile}"></footer> |
<footer class="container-fluid …">…</footer>: das Fragment ersetzt das Element |
<footer th:insert="~{fragmente/rahmen :: fusszeile}"></footer> |
<footer><footer class="container-fluid …">…</footer></footer>: das Fragment steht im Element |
Für ganze Seitenbereiche ist th:replace meist die richtige Wahl, sonst entstehen doppelte Elemente wie oben. Ältere Anleitungen nennen noch th:include; es ist seit Thymeleaf 3.0 veraltet. Statt eines Namens kann der Fragmentausdruck auch einen CSS-ähnlichen Selektor enthalten, etwa ~{fragmente/rahmen :: #hauptnavigation} für das Element mit dieser id.
Die Abbildung zeigt, welche Teile der Seite nach dem Umbau woher kommen:
Thymeleaf kennt noch einen zweiten Weg zu einem gemeinsamen Seitenrahmen: Beim Layout-Ansatz beschreibt eine Datei die ganze Seite mit Lücken, und jede Seite füllt nur diese Lücken. Dafür gibt es die Erweiterung Thymeleaf Layout Dialect mit eigenen Attributen. Das Team bleibt bei Fragmenten mit th:replace, weil sie ohne zusätzliche Abhängigkeit auskommen und für wenige Seiten übersichtlich genug sind.
Ein Fragment mit Parametern für beide Tabellen
Die beiden Tabellen „Zur Schule“ und „Nach Hause“ unterscheiden sich nur in drei Punkten: der id des Abschnitts (für die Sprungmarken der Navigation), der Überschrift und der Liste. Das ist ein Fall für ein Fragment mit drei Parametern in templates/fragmente/abfahrten.html:
<section th:fragment="tabelle(bereich, ueberschrift, eintraege)" th:id="${bereich}" id="zur-schule"
class="col-12 col-xl-6 col-xxl-4">
<h2 class="h3 text-primary" th:text="${ueberschrift}">Zur Schule</h2>
<p th:if="${eintraege.isEmpty()}" class="fs-4" th:text="#{flurmonitor.keine-fahrten}">Gerade keine offenen Fahrten – schau in die App.</p>
<div th:unless="${eintraege.isEmpty()}" class="table-responsive">
<table class="table table-striped align-middle caption-top abfahrten">
<caption th:text="|Offene Fahrtangebote: ${ueberschrift}|">Offene Fahrtangebote: Zur Schule</caption>
<thead>…</thead>
<tbody>
<tr th:each="eintrag, status : ${eintraege}" th:object="${eintrag}" …>…</tr>
<!-- zweite Beispielzeile nur für die Ansicht ohne Server -->
<tr th:remove="all">
<td><time datetime="2026-11-16">2026-11-16</time></td>
<td><time datetime="07:05">07:05</time></td>
<td>Herford</td>
<td>Park & Ride am Bahnhof</td>
<td class="text-end"><span class="badge text-bg-warning">1 (letzter)</span></td>
</tr>
</tbody>
</table>
</div>
</section>In flurmonitor.html wird das Fragment zweimal mit verschiedenen Werten eingebunden. Die Werte stehen in der Klammer in der Reihenfolge der Parameter; Texte in einfachen Anführungszeichen, Werte aus dem Model als Variablenausdruck:
<div class="row g-4">
<section th:replace="~{fragmente/abfahrten :: tabelle('zur-schule', 'Zur Schule', ${zurSchule})}">…</section>
<section th:replace="~{fragmente/abfahrten :: tabelle('nach-hause', 'Nach Hause', ${nachHause})}">…</section>
<aside class="col-12 col-xxl-4 order-first order-xl-last">…</aside>
</div>Die zweite Beispielzeile mit th:remove="all" zeigt einen weiteren Baustein des Natural Templating: Ohne Server ist sie zu sehen, damit die Tabelle im Entwurf nicht nach einer einzigen Zeile aussieht. Beim Rendern entfernt Thymeleaf sie samt Inhalt. Andere Werte von th:remove entfernen nur den Inhalt (body), nur das Element ohne Inhalt (tag) oder alle Kinder bis auf das erste (all-but-first). HTML-Kommentare wie <!-- zweite Beispielzeile … --> bleiben dagegen in der Ausgabe stehen. Soll ein Kommentar nur im Template stehen, schreibst Du ihn als <!--/* … */-->; diese Form entfernt Thymeleaf beim Rendern.
Der Hinweis der Schulverwaltung bleibt vorerst fest im Template. Er kommt mit MON-07 aus der Datenbank, und dann gilt auch für ihn: th:text, nicht th:utext.
Bootstrap als WebJar statt vom CDN
Bisher lädt der Browser im Foyer Bootstrap von cdn.jsdelivr.net. Für eine öffentliche Webseite ist ein Content Delivery Network (CDN) praktisch. Für den Flurmonitor hat es drei Nachteile:
Abhängigkeit vom Netz: Ist der Internetzugang der Schule gestört oder sperrt ein Filter das CDN, erscheint die Seite ungestaltet, obwohl der Server im Haus läuft.
Datenschutz: Jeder Aufruf schickt die IP-Adresse des Monitors an einen fremden Anbieter. Für das Mitfahrlabor, das bei personenbezogenen Daten ohnehin streng ist, ist das ein unnötiger Datenfluss.
Zwei Stellen für eine Version: Die Version steht im Template, alle anderen Abhängigkeiten stehen in
build.gradle.ktsbzw.pom.xml.
Eine Alternative sind WebJars: Das sind gewöhnliche JAR-Dateien, die eine Web-Bibliothek wie Bootstrap enthalten und über Maven Central verteilt werden. Sie kommen wie jede andere Abhängigkeit ins Projekt. Spring Boot liefert ihren Inhalt ohne weitere Konfiguration unter dem Pfad /webjars/** aus. Dazu kommt der kleine webjars-locator-lite, dessen Version Spring Boot verwaltet. Er sorgt dafür, dass Templates die Dateien ohne Versionsnummer ansprechen können:
In build.gradle.kts im Block dependencies:
// Bootstrap als WebJar aus dem eigenen Server statt vom CDN; der Locator erlaubt Pfade ohne Versionsnummer
implementation("org.webjars:bootstrap:5.3.8")
implementation("org.webjars:webjars-locator-lite")Im JAR liegt Bootstrap unter META-INF/resources/webjars/bootstrap/5.3.8/. Daraus wird die URL /webjars/bootstrap/5.3.8/css/bootstrap.min.css; dank des Locators funktioniert auch /webjars/bootstrap/css/bootstrap.min.css. Im Fragment kopf steht deshalb die kurze Form im Link-Ausdruck: th:href="@{/webjars/bootstrap/css/bootstrap.min.css}". Im gerenderten HTML erscheint trotzdem der Pfad mit Versionsnummer, denn beim Auflösen des Link-Ausdrucks ergänzt Spring die Version des WebJars. Das hat einen Vorteil: Nach einem Versionswechsel ändert sich die URL, und kein Browser verwendet versehentlich die alte Datei aus seinem Zwischenspeicher.
Zwei Dinge entfallen mit dem WebJar: das Attribut integrity mit dem SRI-Hash und crossorigin. Beide schützen vor einer veränderten Datei auf einem fremden Server; kommt die Datei vom eigenen Server, gibt es diesen fremden Server nicht mehr.
Eine Kleinigkeit fehlt noch, und sie fällt leicht erst im Foyer auf: Spring Security gibt bisher nur /flurmonitor/**, /css/** und /bilder/** ohne Anmeldung frei. Ohne Ergänzung bekäme der Browser für Bootstrap ein 401, und die Seite erschiene ungestaltet. Die Regel aus dem letzten Artikel bekommt deshalb einen vierten Pfad:
.requestMatchers(HttpMethod.GET, "/flurmonitor/**", "/css/**", "/bilder/**", "/webjars/**").permitAll()Welcher Weg wann passt, fasst die Tabelle zusammen:
| CDN | WebJar | npm und Build-Werkzeug | |
|---|---|---|---|
| woher kommt die Datei | fremder Server | eigener Server, aus einem JAR | eigener Server, aus node_modules gebündelt |
| Version steht in | dem Template | build.gradle.kts bzw. pom.xml |
package.json |
| läuft ohne Internet | nein | ja | ja |
| Anpassen über Sass | nein | eingeschränkt | ja |
| passt zu | einfachen statischen Seiten | serverseitig gerenderten Seiten im Java-Projekt | Single-Page-Apps wie in der Vue-Reihe |
Den Smoke-Test erweitern
Der Smoke-Test aus dem letzten Artikel prüft schon, dass die Seite ohne Anmeldung als HTML ankommt. Das Team ergänzt zwei Tests für die neuen Teile. Die Uhr steht weiter fest auf Dienstag, 20.10.2026, 06:00 Uhr:
@Test
void seiteZeigtDieOffenenFahrtenOhneNamen() {
assertThat(mvc.get().uri("/flurmonitor"))
.hasStatusOk()
.bodyText()
.contains("Herford", "Park & Ride am Bahnhof", "06:50", "Bad Salzuflen", "06:45")
.contains("<tr class=\"naechste-abfahrt\">")
.contains("Gerade keine offenen Fahrten – schau in die App.")
.doesNotContain("Ostwind", "Schulz", "Turbo", "Fischer");
}
@Test
void bootstrapKommtAlsWebJarAusDemEigenenServer() {
assertThat(mvc.get().uri("/flurmonitor"))
.bodyText()
// der Link-Ausdruck @{…} ergänzt die Version des WebJars
.contains("/webjars/bootstrap/5.3.8/css/bootstrap.min.css",
"/webjars/bootstrap/5.3.8/js/bootstrap.bundle.min.js")
.doesNotContain("cdn.jsdelivr.net");
assertThat(mvc.get().uri("/webjars/bootstrap/5.3.8/css/bootstrap.min.css"))
.hasStatusOk()
.hasContentTypeCompatibleWith("text/css");
assertThat(mvc.get().uri("/webjars/bootstrap/css/bootstrap.min.css")).hasStatusOk();
}Der erste Test deckt T8, T9 und den ersten Teil von AK-MON-01.3 ab: Die beiden offenen Fahrten erscheinen, Park & Ride ist maskiert, die erste Zeile trägt die Klasse für die nächste Abfahrt, und weil keine Fahrt nach Hause offen ist, steht der Satz aus AK-MON-01.4 auf der Seite. Die Pseudonyme und Nachnamen der beiden Fahrer*innen kommen nicht vor. Der zweite Test sichert T11: Das HTML verweist auf das WebJar, nicht auf das CDN, und die Datei ist ohne Anmeldung erreichbar.
Ein Smoke-Test mit den Startdaten kann aber nicht jeden Fall herbeiführen, etwa eine Fahrt mit genau einem freien Platz. Gezielte Tests der Web-Schicht mit selbst gewählten Daten folgen im Artikel zum Integrationstest der Web-Schicht. Gestartet werden die Tests wie im letzten Artikel, zum Beispiel mit ./gradlew test bzw. ./mvnw test.
Ansehen
Startest Du das Backend und rufst http://localhost:8080/flurmonitor auf, zeigt die Seite die offenen Fahrten ab dem heutigen Tag. Weil die Startdaten im Oktober 2026 liegen, erscheint bei einem späteren Start in beiden Spalten der Satz für die leere Liste. Das Bildschirmfoto zeigt die Seite mit der Uhr des Smoke-Tests, also am 20.10.2026 um 06:00 Uhr:
Im Quelltext der Seite steht kein th:-Attribut mehr, keine Adresse eines CDN und keine Beispielzeile aus dem Entwurf. Der Nu Html Checker meldet für das gerenderte HTML keinen Fehler. In den Entwicklerwerkzeugen des Browsers siehst Du, dass alle Dateien von localhost:8080 kommen.
Code-Stand
Den vollständigen Stand dieses Artikels findest Du im Ordner src/a03-thymeleaf-templates des Backend-Repositorys. Gegenüber dem Stand a02 sind neu bzw. geändert:
| Datei | Änderung |
|---|---|
build.gradle.kts, pom.xml |
Abhängigkeiten org.webjars:bootstrap:5.3.8 und webjars-locator-lite |
web/FlurmonitorController.java |
Listen zurSchule und nachHause aus MonitorEintrag im Model |
web/SicherheitsKonfiguration.java |
/webjars/** ohne Anmeldung |
resources/templates/flurmonitor.html |
Seite aus Fragmenten, Tabellen per Fragment mit Parametern |
resources/templates/fragmente/rahmen.html |
Fragmente kopf(titel), navigation, fusszeile, skripte |
resources/templates/fragmente/abfahrten.html |
Fragment tabelle(bereich, ueberschrift, eintraege) mit th:each, th:if, th:unless, th:classappend |
resources/messages.properties |
Text für die leere Liste |
resources/static/css/flurmonitor-bootstrap.css |
Klasse naechste-abfahrt |
test/…/web/FlurmonitorSmokeTest.java |
Tests für Zeilen, leere Liste, fehlende Namen und WebJar |
Nächster Schritt im Sprint
Die zweite Aufgabe von MON-01 liegt im Review. Im Daily Scrum fällt Jonas Wolff auf, dass die Seite Fahrten der ganzen Woche zeigt und das Datum noch als 2026-10-20 erscheint. Beides ist Absicht: Das Zeitfenster bis zum Ende des nächsten Schultags und die Sortierung (MON-03) sind die nächste Aufgabe im Sprint Backlog. Im Artikel Ansichtsmodell baut das Team dafür einen eigenen Datentyp für die Anzeige, der den MonitorEintrag im Model ablöst, und prüft ihn mit Komponententests. Die Lesbarkeit des Datums folgt in Sprint 2.
In den Übungsaufgaben zu diesem Artikel ordnest Du Ausdrucksarten zu, sagst die Ausgabe einer Schleife voraus, baust die Tabelle der Mitfahrgesuche mit th:each und Fragmenten und schließt eine XSS-Lücke.
Leitfragen
- Was ist ein Natural Template, und welchen Nutzen hat es?
Ein Template, das gültiges HTML mit zusätzlichen th:-Attributen ist. Ohne Server zeigt der Browser die Beispielwerte und ignoriert die Attribute; auf dem Server ersetzt Thymeleaf die Beispielwerte durch Daten und entfernt die Attribute. So kann die Gestaltung an derselben Datei arbeiten, die der Server verwendet. siehe Abschnitt „Natural Templates“
- Wofür stehen
${…},*{…},@{…}und#{…}?
${…} liest einen Wert aus dem Model oder einer Variablen, *{…} eine Eigenschaft des mit th:object ausgewählten Objekts, @{…} erzeugt eine URL mit Kontextpfad, #{…} holt einen Text aus messages.properties. siehe Abschnitt „Vier Arten von Ausdrücken“
- Warum ist
th:utextbei Daten aus Benutzereingaben gefährlich?
th:utext gibt den Wert ungeprüft als HTML aus. Enthält er ein Element mit Skriptcode, etwa <img … onerror="…">, führt der Browser jedes Betrachters ihn aus (Cross-Site-Scripting). th:text maskiert <, >, & und Anführungszeichen, sodass der Wert nur als Text erscheint. siehe Abschnitt „Werte ausgeben“
- Was leistet die Statusvariable von
th:each?
Die Statusvariable beschreibt den aktuellen Durchlauf: index (ab 0), count (ab 1), size, current, first, last, odd, even. Ohne eigene Angabe heißt sie wie die Laufvariable mit der Endung Stat. siehe Abschnitt „Zeilen wiederholen“
- Warum genügt
th:if="${eintraege}"nicht, um eine leere Liste abzufangen?
Thymeleaf wertet jedes Objekt, das nicht null ist, als wahr, auch eine leere Liste. Nur Booleans, Zahlen und einige Texte wie "false" haben eigene Regeln. Die Bedingung muss deshalb ausdrücklich ${eintraege.isEmpty()} bzw. ${#lists.isEmpty(eintraege)} prüfen. siehe Abschnitt „Bedingungen“
- Was unterscheidet
th:classappendvonth:class?
th:classappend hängt eine Klasse an die vorhandenen Klassen an; liefert der Ausdruck null, bleibt das class-Attribut unverändert. th:class ersetzt das ganze Attribut, vorhandene Klassen wie badge gingen verloren. siehe Abschnitt „Bedingungen“
- Wie definierst Du ein Fragment mit Parameter, und was unterscheidet
th:replacevonth:insert?
Mit th:fragment="kopf(titel)" an einem Element; eingebunden wird es mit th:replace="~{fragmente/rahmen :: kopf('Titel')}". th:replace ersetzt das Element mit dem Attribut durch das Fragment, th:insert setzt das Fragment in das Element hinein. siehe Abschnitt „Fragmente“
- Welche Vorteile hat Bootstrap als WebJar gegenüber dem CDN für den Flurmonitor, und was muss in der Sicherheitskonfiguration ergänzt werden?
Die Seite funktioniert ohne Internetzugang, schickt keine Daten an einen fremden Anbieter, und die Version steht im Build wie alle anderen Abhängigkeiten. Spring Boot liefert WebJars unter /webjars/** aus; dieser Pfad muss für GET ohne Anmeldung freigegeben werden, sonst antwortet der Server mit 401. siehe Abschnitt „Bootstrap als WebJar“
Links und weitere Informationen
für den Einstieg: Ein Flurmonitor mit Thymeleaf: das Projekt im Überblick
als Grundlage: HTML-Grundlagen, CSS-Grundlagen und Bootstrap-Grundlagen
zum Szenario: Szenario Mitfahrlabor mit Personas und Flurmonitoren
als Nachschlagewerk: Tutorial: Using Thymeleaf (Standard-Dialekt, Ausdrücke, Iteration, Bedingungen, Fragmente)
als Nachschlagewerk: Tutorial: Thymeleaf + Spring
als Nachschlagewerk: Spring Boot Reference: statische Inhalte und WebJars
zum Weiterlesen: OWASP: Cross Site Scripting Prevention Cheat Sheet
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Thymeleaf-Templates für den Flurmonitor: Ausdrücke, Schleifen, Bedingungen, Fragmente und Bootstrap als WebJar” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/flurmonitor-thymeleaf-templates veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
