Reusable UI Components in Thymeleaf with Parameterized Fragments
Thymeleaf parameterized fragments let one HTML file serve as a component library. Learn how th:fragment, th:replace, and th:insert differ, with a worked badge/card example and the scoping and runtime-failure pitfalls to avoid.
03 Oct 2026, 22:16 UTC

Duplicating the same card, badge, or table-row markup across a dozen Thymeleaf templates is the usual way a server-rendered UI rots. The supported fix is parameterized fragments: declare a chunk of HTML once with th:fragment=\"name(params)\", then insert it anywhere with arguments, like calling a function that returns markup. One file can act as a component library for the whole application.
How fragment insertion actually works
A fragment is declared on any element with th:fragment. The optional parameter list is evaluated in the caller's context when the fragment is inserted, so the fragment file itself stays free of page-specific model knowledge. You insert it with a template expression of the form ~{template :: fragment(args)}, which the configured template resolver resolves exactly like a full view name — same prefix, same suffix.
There are three insertion attributes, and confusing them is the most common structural bug:
th:replace— the host tag is removed and the fragment's tag takes its place. This is what you usually want for components.th:insert— the fragment is placed inside the host tag, so the host tag survives in the output.th:include— copied only the fragment's body. It was deprecated in Thymeleaf 3.0 and removed in 3.1; older tutorials that use it will fail on current versions.
A worked example: a reusable badge and card
Put shared components in one file, for example src/main/resources/templates/fragments/components.html in a Spring Boot project:
<!DOCTYPE html>
<html xmlns:th=\"http://www.thymeleaf.org\">
<body>
<span th:fragment=\"badge(label, level)\"
th:classappend=\"'badge badge-' + ${level}\"
th:text=\"${label}\">Prototype</span>
<div th:fragment=\"card(title, items)\" class=\"card\">
<h2 th:text=\"${title}\">Card title</h2>
<ul>
<li th:each=\"item : ${items}\" th:text=\"${item}\">Item</li>
</ul>
</div>
</body>
</html>
Insert them from any view. Arguments are SpringEL expressions evaluated against the caller's model, so they can be literals, model attributes, or utility objects like #dates:
<div th:replace=\"~{fragments/components :: badge('OK', 'success')}\"></div>
<section th:replace=\"~{fragments/components :: card(${order.customerName}, ${order.lineNames})}\"></section>
With th:replace, the rendered page contains the <span> and the card <div> — the placeholder <div> and <section> hosts are gone. Had you used th:insert, the output would nest the fragment inside those hosts, which often breaks CSS selectors and grid layouts that expect the component at a specific level.
Because Thymeleaf templates are valid HTML (\"natural templating\"), you can open components.html directly in a browser and see the prototype content — \"Prototype\", \"Card title\", \"Item\" — which makes design review possible without starting the application. Keep that prototype text meaningful; it doubles as documentation.
Scoping rules that bite people
Fragment parameters create local variables inside the fragment, and th:with declarations inside a fragment are scoped to that fragment. They do not leak back into the calling template. If the caller needs a value the fragment computed, recompute it in the caller or pass it in — do not rely on the fragment's internals.
Conversely, fragments can still see the caller's context (model attributes, beans, #utility objects). That is convenient but is also a trap: a fragment that silently depends on a model attribute named user will render empty or fail on any page that forgets to add it. Prefer explicit parameters for anything the fragment truly needs, and treat ambient context as a last resort.
Limits and common mistakes
- Failures are runtime-only. A typo in
~{fragments/components :: bade(...)}compiles fine and throws aTemplateProcessingExceptionnaming the missing fragment only when the page is hit. Integration tests that render each view (even a simple Spring MockMvc smoke test per controller) catch these before users do. - Long parameter lists. A fragment with eight positional parameters is unreadable and easy to call with arguments in the wrong order. Pass a domain object (
card(${order})) once you need more than two or three values. - Over-nesting. Fragments inserting fragments inserting fragments make render-time errors hard to trace and complicate template caching. Two levels is usually plenty.
- Version drift. Check your Thymeleaf version in the build file (Spring Boot 3.x pulls Thymeleaf 3.1+). If
th:includeappears anywhere in your templates, replace it — withth:insertfor closest behavior — before upgrading.
Verifying the result
Three quick checks confirm a fragment setup works: render the calling page and confirm the HTML contains the fragment's tag instead of the host tag (proving th:replace behaved); open the fragment file directly in a browser and confirm it displays as plain HTML; then deliberately misspell the fragment name once and reload, so you recognize the exception message when it happens for real. None of this is testable at build time by the compiler, so a render-level smoke test is the durable safeguard.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.