Spring Boot Tutorial
Server-Side Rendering with Thymeleaf
Not every Spring Boot application is a JSON API behind a React front end. Admin panels, internal tools, dashboards, email templates and content sites are often faster to build — and better for SEO — as server-rendered HTML. Thymeleaf is the template engine Spring Boot supports best: its templates are valid HTML files that designers can open in a browser, enriched with th:* attributes.
This lesson builds a small course catalogue with Spring MVC and Thymeleaf: controllers returning views, expressions, loops and conditionals, reusable layout fragments, forms with validation errors, Spring Security integration, and the Post/Redirect/Get pattern.
Setup and Conventions
Add spring-boot-starter-thymeleaf alongside spring-boot-starter-webmvc. Templates live in src/main/resources/templates with the .html suffix; static files (CSS, JS, images) go in src/main/resources/static. A @Controller (not @RestController) method returns a view name such as "courses/list", which resolves to templates/courses/list.html. Data passed through the Model is available in the template.
Expression Syntax
The main expression types are:
${...}— variable expressions:${course.title}.*{...}— selection expressions on the object chosen withth:object, used in forms.@{...}— link URLs with parameters:@{/courses/{slug}(slug=${c.slug})}.#{...}— messages frommessages.propertiesfor internationalisation.~{...}— fragment references for layouts.- Attributes:
th:text(escaped),th:each,th:if/th:unless,th:href,th:field,th:classappend,th:replace.
Forms and Validation
Bind a form to a backing object with th:object and th:field. In the controller, accept @Valid @ModelAttribute followed immediately by a BindingResult; if it has errors, return the form view again and Thymeleaf shows messages with th:errors. On success, redirect (Post/Redirect/Get) so refreshing the page does not resubmit the form, and use RedirectAttributes flash attributes for success messages.
Security and Escaping
th:text escapes HTML, protecting you from XSS; th:utext does not and must never be used with user input. Forms rendered with th:action automatically include Spring Security's CSRF token. The thymeleaf-extras-springsecurity6 dialect adds sec:authorize and sec:authentication to show content based on roles.
Development Tips
Templates are cached in production. With Spring Boot DevTools, caching is disabled automatically, so changes appear on browser refresh. Keep templates simple: prepare data in the controller or a view model record rather than calling services from templates.
Examples
Controller returning views with model data
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
@Controller
@RequestMapping("/courses")
public class CoursePageController {
private final CourseService courses;
public CoursePageController(CourseService courses) {
this.courses = courses;
}
@GetMapping
public String list(@RequestParam(required = false) String level, Model model) {
model.addAttribute("courses", courses.findAll(level));
model.addAttribute("level", level);
return "courses/list"; // -> templates/courses/list.html
}
@GetMapping("/{slug}")
public String detail(@PathVariable String slug, Model model) {
model.addAttribute("course", courses.findBySlug(slug));
return "courses/detail";
}
}
(GET /courses renders templates/courses/list.html with the "courses" and "level" model attributes.)
A template with layout fragment, loop, conditionals and links
<!-- templates/fragments/layout.html -->
<header th:fragment="header">
<a th:href="@{/}">Webnest Studio</a>
<span sec:authorize="isAuthenticated()">Hi, <b sec:authentication="name">user</b></span>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/admin/courses/new}">New course</a>
</header>
<!-- templates/courses/list.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>Courses</title>
<link rel="stylesheet" th:href="@{/css/site.css}">
</head>
<body>
<div th:replace="~{fragments/layout :: header}"></div>
<h1 th:text="${level} ? 'Courses for ' + ${level} : 'All courses'">All courses</h1>
<p th:if="${#lists.isEmpty(courses)}">No courses found.</p>
<ul>
<li th:each="c, stat : ${courses}" th:classappend="${stat.odd} ? 'odd'">
<a th:href="@{/courses/{slug}(slug=${c.slug})}" th:text="${c.title}">Course title</a>
<span th:text="|${c.lessons} lessons · ${c.level}|">10 lessons</span>
<strong th:unless="${c.free}" th:text="${#numbers.formatDecimal(c.price, 1, 2)}">0.00</strong>
</li>
</ul>
</body>
</html>
<h1>All courses</h1>
<ul>
<li class="odd"><a href="/courses/java-core">Java - Core</a> <span>60 lessons · BEGINNER</span> <strong>1499.00</strong></li>
<li><a href="/courses/spring-boot">Spring Boot</a> <span>100 lessons · INTERMEDIATE</span> <strong>2999.00</strong></li>
</ul>
Form with validation errors and Post/Redirect/Get
public class CourseForm {
@NotBlank private String title;
@Pattern(regexp = "[a-z0-9-]+", message = "lowercase letters, digits and dashes only")
private String slug;
@DecimalMin("0.0") private BigDecimal price;
// getters and setters (Thymeleaf binds through JavaBean properties)
}
@Controller
@RequestMapping("/admin/courses")
public class AdminCourseController {
@GetMapping("/new")
public String form(Model model) {
model.addAttribute("courseForm", new CourseForm());
return "admin/course-form";
}
@PostMapping
public String create(@Valid @ModelAttribute CourseForm courseForm, BindingResult errors,
RedirectAttributes redirect) {
if (errors.hasErrors()) {
return "admin/course-form"; // show the form again with messages
}
courseService.create(courseForm);
redirect.addFlashAttribute("message", "Course created");
return "redirect:/courses"; // PRG: a refresh won't resubmit
}
}
<!-- templates/admin/course-form.html -->
<form th:action="@{/admin/courses}" th:object="${courseForm}" method="post">
<label>Title <input th:field="*{title}"></label>
<p class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></p>
<label>Slug <input th:field="*{slug}"></label>
<p class="error" th:errors="*{slug}"></p>
<label>Price <input th:field="*{price}" type="number" step="0.01"></label>
<button type="submit">Save</button>
</form>
POST /admin/courses title="" slug="Spring Boot!"
-> form re-rendered:
<p class="error">must not be blank</p>
<p class="error">lowercase letters, digits and dashes only</p>
POST /admin/courses title="Spring AI" slug="spring-ai" price=2999
-> 302 /courses, flash message "Course created"
Common Mistakes
- Using @RestController for page controllers, so the view name is returned as plain text instead of rendering the template.
- Placing BindingResult anywhere other than directly after the @Valid parameter, causing a 400 error instead of showing the form.
- Using th:utext with user-supplied content, creating XSS vulnerabilities.
- Returning a view after a successful POST instead of redirecting, so browser refresh submits the form again.
- Calling repositories or services from templates instead of preparing data in the controller.
Key Points to Remember
- spring-boot-starter-thymeleaf renders templates from src/main/resources/templates.
- @Controller methods return view names; Model attributes are available as ${...} in templates.
- th:object/th:field bind forms; @Valid + BindingResult + th:errors display validation messages.
- Use Post/Redirect/Get with flash attributes after successful form submissions.
- th:text escapes output; forms get CSRF tokens automatically; sec:authorize shows role-based content.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.