Course topics

By WebNest Studio

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 with th:object, used in forms.
  • @{...} — link URLs with parameters: @{/courses/{slug}(slug=${c.slug})}.
  • #{...} — messages from messages.properties for 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

Java
<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";
    }
}
Output
(GET /courses renders templates/courses/list.html with the "courses" and "level" model attributes.)

A template with layout fragment, loop, conditionals and links

Java
<!-- 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>
Output
<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

Java
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>
Output
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.