Spring Boot – Server-Rendered Pages with Thymeleaf

July 9, 20265 min readUpdated 8/18/2026

Not every page needs a JavaScript framework. When the output is a document — a receipt, an invoice, an email body, something to be printed — rendering it on the server is simpler and more robust than shipping JSON to a client that reassembles it.

Thymeleaf is Spring Boot's default template engine, and its distinguishing feature is that a template is valid HTML. You can open it in a browser and see a sensible page with placeholder content, because every dynamic instruction is an attribute rather than a tag.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
# Template caching OFF for development so an edit to receipt.html shows on refresh.
# Boot's own default is true, and leaving it that way locally means every template
# change looks like it did nothing until the app is restarted.
spring.thymeleaf.cache=false

Templates live in src/main/resources/templates/ and resolve by name, so returning "receipt" renders templates/receipt.html.

⚠️ @Controller, not @RestController

This single difference is the whole lesson's most common bug:

@Slf4j
@Controller                 // NOT @RestController
@RequiredArgsConstructor
@Hidden
public class OrderReceiptController {

    private final CustomerOrderService orderService;

    @GetMapping("/orders/{id}/receipt")
    public String receipt(@PathVariable UUID id, Model model) {
        OrderDTO order = orderService.getOrderByPublicId(id);
        model.addAttribute("order", order);

        return "receipt";   // a VIEW NAME, resolved to templates/receipt.html
    }
}

@RestController is @Controller + @ResponseBody, and @ResponseBody means "serialise the return value into the response". Here the return value is the string "receipt" — so with @RestController the browser receives the literal seven characters receipt. A blank page showing one word, and no error anywhere.

Put the DTO in the model, not the entity. Rendering happens after the controller returns, by which time open-in-view=false has closed the persistence context. A lazy association touched in the template throws LazyInitializationException from inside the view layer, which is a confusing place to find a database error.

The syntax worth knowing

Five attributes cover almost everything.

th:text — replace an element's body

<title th:text="${title}">Pizza</title>

The placeholder Pizza is what you see opening the file directly, and it is replaced at render time. That is the "natural template" idea: a designer can work on the file without running the application.

th:text escapes HTML, which is the correct default and your XSS protection. th:utext does not escape — reserve it for HTML you generated yourself, never for anything a user typed.

th:if / th:unless — conditionals

<p th:if="${order.orderType.name() == 'DELIVERY'}">
  <strong>Delivering to</strong><br />
  <span th:text="${order.customerName}">name</span><br />
  <span th:text="${order.addressLine1}">line 1</span>
</p>
<p th:unless="${order.orderType.name() == 'DELIVERY'}">
  <strong>Collection</strong>
</p>

Note that th:if removes the element entirely rather than hiding it with CSS. Nothing sensitive ships in the markup, which is a meaningful difference from display: none.

th:each — loops

<tr th:each="item : ${order.items}">
  <td>
    <span th:text="${item.productName}">Product</span>
    <span th:if="${item.size}" th:text="|(${item.size})|">(LARGE)</span>
    <div th:if="${not #lists.isEmpty(item.toppings)}" class="toppings"
         th:text="'Toppings: ' + ${#strings.listJoin(item.toppings.![toppingName], ', ')}">
      Toppings
    </div>
  </td>
  <td th:text="${item.quantity}">1</td>
  <td th:text="'$' + ${#numbers.formatDecimal(item.lineTotal, 1, 2)}">$0.00</td>
</tr>

An optional second variable gives you iteration status — th:each="item, stat : ${items}" — with stat.index, .count, .first, .last, .odd and .even. That is how you stripe rows or special-case the last one without tracking a counter yourself.

Literal substitution and utility objects

|…| is a literal substitution block — inline ${} without breaking the string into a chain of concatenations:

<span th:text="|Order ${order.id}|">Order …</span>
<span th:text="|${order.city}, ${order.state} ${order.postalCode}|">city</span>

The #-prefixed names are expression utility objects:

ObjectUsed for
#numbersformatDecimal(total, 1, 2) — money
#temporalsformat(createdAt, 'd MMM yyyy, HH:mm')
#stringslistJoin, isEmpty, abbreviate
#listsisEmpty, size

They exist so formatting stays in the template. Building the joined toppings string in the controller would put a presentation concern in the wrong layer.

th:fragment and th:replace — reuse

A fragment is a named, reusable chunk — Thymeleaf's answer to the copy-and-pasted header that drifts out of step between pages:

<!-- fragments/layout.html -->
<head th:fragment="head(title)">
  <meta charset="utf-8" />
  <title th:text="${title}">Pizza</title>
</head>

<div th:fragment="brand" th:remove="tag">
  <h1>🍕 Pizza</h1>
  <p class="muted">Thank you for your order</p>
</div>
<!-- receipt.html -->
<head th:replace="~{fragments/layout :: head('Receipt · Order ' + ${order.id})}">
  <title>Receipt</title>
</head>

<div th:replace="~{fragments/layout :: brand}"></div>

Fragments take parameters, as head(title) does. th:remove="tag" drops the wrapper element but keeps its children, so a fragment does not inject a stray <div> into every page that uses it.

The same template, rendered to a string

This is where server-side rendering earns its place in an otherwise JSON application. The confirmation email uses the identical template:

// The Context is the model. Thymeleaf has no idea it is producing an email; it sees the
// same variable name the receipt page sets, which is why one template serves both.
Context context = new Context();
context.setVariable("order", order);
String html = templateEngine.process("receipt", context);

MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setTo(order.email());
helper.setSubject("Your Pizza order " + order.id());
helper.setText(html, true);

Same engine, same template, same model — but no HTTP request, so no view resolution either. process() hands you the HTML and you decide where it goes. Lesson 31 covers the email side.

When to use this, and when not to

Reach for it when the output is a document (receipts, invoices, printable pages), when it must work from an email link with no application to boot, when it is the body of an email, or for small internal admin pages where a React build is more machinery than the page is worth.

Do not reach for it for interactive UI. The pizza API's menu, cart and checkout stay in React because they are stateful and interactive; the receipt is server-rendered because it is a document. Mixing the two deliberately, by output type, is a coherent position — mixing them by accident is not.

What to take from this

  • @Controller returns a view name; @RestController returns the string itself. This is the bug you will hit.
  • DTOs in the model, not entities — rendering happens after the persistence context closes.
  • th:text escapes; th:utext does not. Default to the first.
  • templateEngine.process() renders to a String, which is how the same template becomes an email body.

Next: JPA and Hibernate — entities, the persistence context, and the N+1 problem.