Thymeleaf in Spring Boot: Setup, Syntax, Forms, and Fragments

How I use Thymeleaf in Spring Boot for server-side rendering, from the first dependency to fragments and form binding, with the gotchas that aren't in the docs.

Thymeleaf in Spring Boot: Setup, Syntax, Forms, and Fragments

I used to think server-side rendering was dead. Then I had to build an internal admin dashboard where the frontend team was “two sprints away” from being available. I reached for Thymeleaf because it was the default Spring Boot template option and I was lazy. That was three years ago and I’m still using it for internal tools.

It turns out that for server-rendered pages, Thymeleaf is genuinely good, not “good enough for a prototype” good, but “I’d choose it again” good. Here’s how to get productive with it.

Why Thymeleaf Over the Alternatives?

I’ve used JSP exactly once and hated it. I looked at FreeMarker but the syntax felt alien. Thymeleaf won me over because:

  • Natural templates. Your HTML files are valid HTML. You can open them in a browser and they render (minus the dynamic bits). No special IDE plugin required.
  • Spring Boot just works with it. Auto-configuration kicks in: templates load from src/main/resources/templates: and you’re coding in five minutes.
  • The expression language is readable. ${user.name} does what you think it does. *{email} does what you think it does. No documentation spelunking required.
  • Fragments are powerful. Once you learn how to compose templates with fragments: you stop copying and pasting navbars across fifteen files.

Setting Up Thymeleaf in Spring Boot

One dependency. That’s the entry cost.

Maven

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

Gradle

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

Spring Boot’s autoconfiguration sets up the TemplateEngine and ViewResolver for you. Templates go in src/main/resources/templates/. If you want to change the default prefix or suffix, you can, but I’ve never needed to.

Expression Syntax: The Four Types

This is the core of Thymeleaf. Learn these four and you can do almost anything.

Variable Expressions: ${...}

Access data from the Spring model. This is the one you’ll use the most.

<p th:text="${user.name}">John Doe</p>

The text inside the <p> tag is what shows up when you preview the HTML in a browser without the server running. Thymeleaf replaces it at render time.

Selection Variable Expressions: *{...}

Used with th:object to work with a specific object’s properties. Reduces repetition.

<div th:object="${user}">
    <p th:text="*{email}">john@example.com</p>
</div>

I use this a lot in forms. It makes the template cleaner than writing ${user.email}, ${user.name}, etc. over and over.

Message Expressions: #{...}

For internationalization (i18n). Pulls from your resource bundles.

<h1 th:text="#{welcome.message}">Welcome!</h1>

I’ll be honest, I almost never use this because most of my projects are English-only. But if you’re building something multilingual, it’s there.

Generates URLs and handles context paths automatically.

<a th:href="@{/profile(id=${user.id})}">View Profile</a>

This is a small thing but it matters. If your app is deployed at /admin as a context path, @{/profile} becomes /admin/profile automatically. Without this, you’re concatenating strings and praying.

Attribute Processors: Making HTML Dynamic

These are the th:* attributes that control rendering.

Conditional Rendering

<div th:if="${user.isAdmin}">
    <p>Welcome, Administrator!</p>
</div>
<div th:unless="${user.isActive}">
    <p>Account is inactive.</p>
</div>

th:if and th:unless are opposites. I keep mixing them up and having to check the docs, so pick one and stick with it. I default to th:if and negate the condition in the expression.

Iteration

<ul>
    <li th:each="product : ${products}">
        <span th:text="${product.name}">Product Name</span> - 
        <span th:text="${product.price}">$0.00</span>
    </li>
</ul>

Works exactly like a for-each loop. The first time you use th:each, it feels like writing a JSTL tag. After that, it’s second nature.

Forms and Data Binding

This is where Thymeleaf really shines for Spring Boot apps. The integration with Spring MVC’s form binding is seamless.

The Controller

@Controller
public class UserController {

    @GetMapping("/register")
    public String showForm(Model model) {
        model.addAttribute("user", new User());
        return "registration-form";
    }

    @PostMapping("/register")
    public String submitForm(@ModelAttribute("user") User user) {
        // Process user registration
        return "success";
    }
}

The Template

<form th:action="@{/register}" th:object="${user}" method="post">
    <label>Name:</label>
    <input type="text" th:field="*{name}" />
    
    <label>Email:</label>
    <input type="email" th:field="*{email}" />
    
    <button type="submit">Register</button>
</form>

The th:field attribute is doing a lot of work here, it generates the id, name, and value attributes for the input field. Without it, you’d be writing those manually. The th:object on the form binds to the model attribute, so *{name} resolves to ${user.name}.

This is one of those things that seems like magic until you open the generated HTML and see that it’s just a regular <input> tag. Thymeleaf’s philosophy of “natural templates” really pays off here.

Fragments: Composing Templates

If you’ve got a navbar, footer, or sidebar that appears on every page, you don’t want to copy-paste it into every template. Thymeleaf fragments solve this.

Defining a Fragment

<!-- common.html -->
<div th:fragment="header">
    <nav>
        <a th:href="@{/}">Home</a>
        <a th:href="@{/about}">About</a>
    </nav>
</div>

Using a Fragment

<div th:replace="~{common :: header}"></div>

The ~{...} syntax is a template selector. common :: header means “the fragment named header in the file common.html.”

There are three ways to include fragments, and the difference matters:

  • th:replace: Replaces the host tag entirely with the fragment. This is what I use 90% of the time.
  • th:insert: Inserts the fragment content inside the host tag.
  • th:include: Like th:insert, but only the fragment’s content, not the wrapper tag. (Deprecated in newer Thymeleaf versions, so I avoid it.)

I once spent twenty minutes debugging a layout issue because I used th:insert instead of th:replace and the extra wrapper div was breaking my CSS grid. Such a small thing, such a big headache.

What I Took Away

Thymeleaf isn’t sexy. Nobody’s writing blog posts about how it changed their life. But it does its job well: it renders HTML on the server side, integrates cleanly with Spring Boot, and doesn’t fight you when you need conditional logic, loops, or form binding.

My rule of thumb: if the page is mostly server-rendered content (admin dashboards, internal tools, forms, content pages), Thymeleaf is a solid choice. If you need a rich interactive UI with real-time updates, you probably want a JavaScript framework instead.

For the dashboard I mentioned at the start, I ended up using Thymeleaf for the pages and a sprinkle of Alpine.js for the interactive bits. No build step, no npm, no webpack. Just HTML files that worked. Sometimes the simplest solution is the one you already have.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.