Getting Started
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on
2026-10-07, and every example was compiled and run on that stack (Maven wrapper, H2 in memory, JDK 26 with --release 21). It applies to
Thymeleaf with Spring MVC on a servlet container, which is what the spring-boot-starter-webmvc starter gives you; the
reactive (WebFlux) variant uses the same templates and is covered on the Reactive Rendering with WebFlux page, and the engine used
standalone without Spring on the Standalone Engine & Configuration page. Layout Dialect 4.0.1 and the other add-ons are not needed yet.
The goal is a running, reloadable, database-backed page in a few minutes, and an understanding of where every file goes and
what happens on a request. The running scenario of the section, the Bookshelf (a catalogue of books and authors that later
gains reviews and loans), starts here.
What you are building
Thymeleaf is a template engine, not a web framework: it turns an HTML template plus a model of data into HTML text. Spring MVC
supplies the web part (routing, binding, controllers), and Spring Boot wires the two together through
ThymeleafAutoConfiguration. A request therefore always follows the same path:
GET /books"] --> dispatcher["DispatcherServlet"] dispatcher --> mapping["HandlerMapping
finds BookController.list"] mapping --> controller["@Controller method
fills the Model, returns the view name books/list"] controller --> resolver["ThymeleafViewResolver
view name to ThymeleafView"] resolver --> engine["SpringTemplateEngine
templates/books/list.html"] engine --> html["HTML text"] html --> browser repo[("BookRepository
Spring Data JPA")] --> controller
The controller never produces HTML: it returns a view name (a string such as books/list). The ThymeleafViewResolver
registered by Boot adds the prefix classpath:/templates/ and the suffix .html to find the template, the engine evaluates the
th:* attributes against the model, and the resulting HTML is written to the response. Because the @Controller returns a
view name, a @RestController (or @ResponseBody) must not be used for pages: it would send the string itself.
Create the project with Spring Initializr
Spring Initializr generates a ready-to-build project. For the Bookshelf, choose the Spring Boot 4.1.x release, Java 21, and the dependencies Spring Web, Thymeleaf, Spring Boot DevTools, Validation, Spring Security and Spring Data JPA, plus the H2 Database for a zero-setup start. The same choice can be made from a terminal; the response is a zip file (here for Maven):
curl -s https://start.spring.io/starter.zip \
-d type=maven-project -d language=java -d bootVersion=4.1.1 -d javaVersion=21 \
-d groupId=com.example -d artifactId=bookshelf \
-d dependencies=web,thymeleaf,devtools,validation,security,data-jpa,h2 \
-o bookshelf.zip
unzip bookshelf.zip -d bookshelf
Use -d type=gradle-project for a Gradle build. Initializr resolves the dependency ids to the Boot 4 artifacts, which is worth
knowing because several names changed from Boot 3. The Maven dependencies it generated for the choice above are:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- plus spring-boot-starter-*-test for data-jpa, security, thymeleaf, validation and webmvc, scope test -->
</dependencies>
The Gradle build Initializr generates for the same choice lists the same artifacts, with developmentOnly for DevTools:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-h2console'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
// ... the other *-test starters
}
What this gives you:
-
spring-boot-starter-thymeleafbrings Thymeleaf and its Spring integration (thymeleaf-spring6, which is also the module used with Spring Framework 7: there is nothymeleaf-spring7). No Thymeleaf version appears in the build, because the Spring Boot dependency management pins Thymeleaf 3.1.5.RELEASE, the Layout Dialect (4.0.1) andthymeleaf-extras-data-attribute(2.0.1). -
thymeleaf-extras-springsecurity6is added by Initializr when Spring Security and Thymeleaf are both chosen; it provides thesec:*attributes and Boot registers its dialect automatically. -
spring-boot-starter-thymeleaf-testis the test starter introduced with the modularised Boot 4 test support; withspring-boot-starter-webmvc-testit suppliesMockMvcand the slice-test annotations. -
DevTools is only on the runtime classpath, and it disables itself when the application runs from a packaged jar.
The Bookshelf scenario does not use Layout Dialect or htmx yet; they are added to the pom.xml on the pages that introduce
them. Note that htmx-spring-boot is not managed by the Boot BOM, so the version is given explicitly there (5.2.0 on
2026-10-07).
The project layout
The generated project is an ordinary Boot project (see
Getting Started with Spring Boot for the generic parts). Two folders under
src/main/resources matter to Thymeleaf:
bookshelf/
├── pom.xml (or build.gradle)
└── src/
├── main/
│ ├── java/com/example/bookshelf/
│ │ ├── BookshelfApplication.java
│ │ ├── catalog/ Author, Book, AuthorRepository, BookRepository, SeedData
│ │ └── web/ HelloController, BookController, SecurityConfig
│ └── resources/
│ ├── application.properties
│ ├── application-local.properties
│ ├── templates/ Thymeleaf templates (view names resolve here)
│ │ ├── hello.html
│ │ └── books/
│ │ ├── list.html
│ │ └── detail.html
│ └── static/ served as-is, no templating
│ └── css/app.css
└── test/java/com/example/bookshelf/web/BookControllerTest.java
-
templates/is where the view resolver looks: with the defaultspring.thymeleaf.prefix=classpath:/templates/andspring.thymeleaf.suffix=.html, the view namebooks/listis the filetemplates/books/list.html. Sub-folders are part of the view name. -
static/(alsopublic/,resources/andMETA-INF/resources/) is served directly at the root of the application, sostatic/css/app.cssis/css/app.css. Files here are never processed by Thymeleaf, and they never see the model. -
Templates are not reachable by URL. A request for
/hello.htmldoes not rendertemplates/hello.html; only a controller can select it by view name. This is the main security property of the folder.
Where the Bookshelf ends up
The tree above is the first slice. By the end of this section the same project has this shape: the single catalog package
splits by feature, and the template folder gains one sub-folder per concern.
src/main/java/com/example/bookshelf/
├── book/ Book, BookRepository, BookService, BookController, BookFormData
├── author/ Author, AuthorRepository, AuthorController
├── review/ Review, ReviewRepository, ReviewController
├── security/ SecurityConfig, user details
└── web/ advice, converters, locale and MVC configuration
src/main/resources/
├── templates/
│ ├── layout/ the decorating page layout
│ ├── fragments/ form fields, pagination, icons, modals, alerts
│ ├── books/ authors/ reviews/
│ ├── error/ 404.html, 5xx.html
│ └── mail/ HTML and TEXT e-mail templates
├── static/ css, js, images
├── i18n/ messages.properties, messages_es.properties
└── application.properties, application-local.properties
Nothing in this layout is required by Thymeleaf except that templates sit under the configured prefix. The folders are a convention used across the section: layouts are introduced on the Layouts & the Layout Dialect page, fragments on Fragments, message bundles on Internationalization, error pages on Error Handling & Flash Messages and mail templates on Emails & Text Templates.
Source: Spring Boot — Template Engines
The first controller, Model and template
A controller adds data to the Model and returns a view name:
package com.example.bookshelf.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("name", "Bookshelf");
return "hello";
}
}
The template src/main/resources/templates/hello.html is a valid, browsable HTML file with th:* attributes layered on top. The
xmlns:th declaration is not needed for Thymeleaf to process the attributes in HTML mode; it only keeps editors and validators
quiet:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<p th:text="'Hello, ' + ${name} + '!'">Hello, prototype!</p>
</body>
</html>
The text inside the <p> ("Hello, prototype!") is what a designer sees when opening the file directly in a browser; it is
replaced when the server renders the page. That is the natural template idea, covered on the Introduction & Architecture page. The request GET /hello returns:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
<p>Hello, Bookshelf!</p>
</body>
</html>
The th:text attribute was consumed and its result became the element body; xmlns:th was removed. th:text escapes the
value, so a name containing <script> is shown as text and not executed. The expression syntax used here (${name}, string
concatenation) has a page of its own: Standard Expression Syntax.
Run it
Run the application with the Maven or Gradle plugin; there is no need to deploy anything, because Boot embeds Tomcat:
./mvnw spring-boot:run # Maven
./gradlew bootRun # Gradle
The application listens on localhost:8080 (change it with server.port). Because Spring Security is on the classpath, every
URL is protected by default with a form login and a randomly generated password printed to the log at startup. The Bookshelf
replaces that with its own SecurityFilterChain, kept deliberately small here (the Spring Security Integration page does it
properly): the stylesheet and /hello are public, everything else needs authentication, and HTTP Basic is enabled in addition to
the form login so that curl can authenticate:
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/hello").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
An unauthenticated request to /books is redirected (302) to /login, which is the correct behaviour for a browser. For the
rest of the page the commands use the development user defined in the local profile below.
Reload templates while developing
By default Thymeleaf caches parsed templates (spring.thymeleaf.cache=true), so an edited template is not picked up until the
application restarts. Three mechanisms remove that wait:
-
DevTools property defaults. With
spring-boot-devtoolson the classpath, Boot sets development-friendly defaults, includingspring.thymeleaf.cache=false,spring.web.resources.cache.period=0,spring.web.resources.chain.cache=falseandspring.h2.console.enabled=true, and makes the error page include the message, binding errors and stack trace. The log saysDevtools property defaults active!. Setspring.devtools.add-properties=falseto turn the defaults off. -
An explicit property.
spring.thymeleaf.cache=falsedoes the same without DevTools. Put it in a development-only profile (next section), never in the baseapplication.properties, or the production build re-parses every template on every request. -
No restart for templates. DevTools restarts the application when classpath files change, but changes under
/templates,/static,/public,/resources,/META-INF/resourcesand/META-INF/mavendo not trigger a restart (configurable withspring.devtools.restart.exclude). With the cache off, the next request simply reads the new file.
There is one catch that surprises almost everyone: the running application reads templates from the build output
(target/classes or build/resources/main), not from src/main/resources. Editing the source file while spring-boot:run is
active changes nothing until the resources are copied. The run below renders "Howdy" only after the copy:
# edit hello.html: 'Hello, ' becomes 'Howdy, '
curl -s localhost:8080/hello | grep '<p>' # <p>Hello, Bookshelf!</p>
./mvnw -q process-resources # copy the changed resource (Gradle: ./gradlew processResources)
curl -s localhost:8080/hello | grep '<p>' # <p>Howdy, Bookshelf!</p>
An IDE does this copy for you on build (in IntelliJ IDEA, Build Project; in Eclipse, saving). Running the application from the
IDE’s own main method can also order the classpath differently from Maven, so the template appears to be missing; reorder the
classpath so that the module’s own classes and resources come first.
LiveReload. DevTools bundles a LiveReload server that asks a browser extension to refresh the page after a change. As of
Spring Boot 4.1.0 the feature is deprecated with no replacement, and it is only started if spring.devtools.livereload.enabled
is true. Do not build a workflow around it: with the cache off, a manual refresh is enough, and a front-end build tool’s own
dev server is the alternative when you use a CSS toolchain (see the Styling & Front-end Assets page).
A local profile
Development-only settings belong in a profile, so they cannot leak into a production run. Create
src/main/resources/application-local.properties:
# Active only with --spring.profiles.active=local
spring.thymeleaf.cache=false
spring.web.resources.cache.period=0
# development user, placeholder password read from the environment
spring.security.user.name=reader
spring.security.user.password=${BOOKSHELF_PASSWORD:change-me}
and keep the shared settings in application.properties:
spring.application.name=bookshelf
spring.jpa.open-in-view=false
Activate the profile from the command line, or with an environment variable (useful for Gradle, where plugin arguments are awkward):
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
SPRING_PROFILES_ACTIVE=local ./gradlew bootRun
The placeholder default change-me is only for a local machine; any deployed environment takes the password from the
environment or a secret store, and real users come from a UserDetailsService. Explicit spring.thymeleaf.cache=false in the
profile is redundant when DevTools is present, but it keeps the intent visible and still works when DevTools is excluded, for
example in a mvn spring-boot:run run with a different classpath.
The Bookshelf: persistence layer
The running example needs data. The persistence layer is plain Spring Data JPA and Hibernate; this section does not re-document it, so the entities below only show what the templates depend on. For mapping details see Spring Data JPA and Entities and Identifiers, for the Boot wiring Hibernate & Spring Boot Integration and Hibernate Getting Started, and for the performance trap used below Fetching & the N+1 Problem.
@Entity
public class Author {
@Id
@GeneratedValue
private Long id;
private String name;
protected Author() { } // required by JPA
public Author(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@Entity
public class Book {
@Id
@GeneratedValue
private Long id;
private String title;
private LocalDate published;
@ManyToOne(fetch = FetchType.LAZY)
private Author author;
protected Book() { }
public Book(String title, LocalDate published, Author author) {
this.title = title;
this.published = published;
this.author = author;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public LocalDate getPublished() { return published; }
public Author getAuthor() { return author; }
}
The repositories declare the query and load the author together with the book:
public interface AuthorRepository extends JpaRepository<Author, Long> { }
public interface BookRepository extends JpaRepository<Book, Long> {
@EntityGraph(attributePaths = "author")
List<Book> findAllByOrderByTitleAsc();
@EntityGraph(attributePaths = "author")
Optional<Book> findWithAuthorById(Long id);
}
Two decisions here are made for the template layer. spring.jpa.open-in-view=false (set above) closes the persistence context
before the view is rendered; with the Boot default (true) the template could silently trigger lazy loads, one query per row,
and Boot logs a warning about it. With it off, everything a template needs must be loaded by the controller’s call, which is what
@EntityGraph does. The entities are also passed straight to the template in this first example; the Displaying Data, Pagination & Sorting and Forms & Data Binding pages
introduce view-specific objects so that templates do not depend on the entity model.
Development data is seeded only under the local profile, so production never runs it:
@Component
@Profile("local")
class SeedData implements CommandLineRunner {
private final AuthorRepository authors;
private final BookRepository books;
SeedData(AuthorRepository authors, BookRepository books) {
this.authors = authors;
this.books = books;
}
@Override
public void run(String... args) {
if (books.count() > 0) {
return;
}
Author le = authors.save(new Author("Ursula K. Le Guin"));
Author borges = authors.save(new Author("Jorge Luis Borges"));
books.save(new Book("The Dispossessed", LocalDate.of(1974, 5, 1), le));
books.save(new Book("A Wizard of Earthsea", LocalDate.of(1968, 11, 1), le));
books.save(new Book("Ficciones", LocalDate.of(1944, 1, 1), borges));
}
}
The H2 database is in memory, so the data is gone at shutdown and recreated by the seed on every start. The first non-throwaway step is to replace it with PostgreSQL (the usual choice, started locally with Docker Compose) and a schema migration tool; the persistence pages linked above cover both, and nothing in the templates changes.
The Bookshelf: controller and templates
@Controller
@RequestMapping("/books")
public class BookController {
private final BookRepository books;
public BookController(BookRepository books) {
this.books = books;
}
@GetMapping
public String list(Model model) {
model.addAttribute("books", books.findAllByOrderByTitleAsc());
return "books/list";
}
@GetMapping("/{id}")
public String detail(@PathVariable Long id, Model model) {
model.addAttribute("book", books.findWithAuthorById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)));
return "books/detail";
}
}
The list template shows three things that matter from day one: every dynamic attribute keeps a working static value
(href="detail.html", the prototype title), links are written as @{...} URLs so that the context path is added, and a missing
value cannot break the page.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Bookshelf</title>
<link rel="stylesheet" href="../../static/css/app.css" th:href="@{/css/app.css}">
</head>
<body>
<h1>Books</h1>
<table>
<thead>
<tr><th>Title</th><th>Author</th><th>Published</th></tr>
</thead>
<tbody>
<tr th:each="book : ${books}">
<td><a href="detail.html" th:href="@{/books/{id}(id=${book.id})}" th:text="${book.title}">A prototype title</a></td>
<td th:text="${book.author.name}">An author</td>
<td th:text="${#temporals.format(book.published, 'yyyy-MM-dd')}">2000-01-01</td>
</tr>
<tr th:if="${#lists.isEmpty(books)}">
<td colspan="3">No books yet.</td>
</tr>
</tbody>
</table>
</body>
</html>
The detail template follows the same pattern:
<h1 th:text="${book.title}">A prototype title</h1>
<p>by <span th:text="${book.author.name}">An author</span>,
published <time th:text="${#temporals.format(book.published, 'yyyy')}">2000</time></p>
<p><a href="list.html" th:href="@{/books}">Back to the list</a></p>
Start the local profile and request the pages. The first request below is unauthenticated, the others use the development
user:
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' localhost:8080/books
302 http://localhost:8080/login
$ curl -s -u reader:change-me localhost:8080/books
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bookshelf</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<h1>Books</h1>
<table>
<thead>
<tr><th>Title</th><th>Author</th><th>Published</th></tr>
</thead>
<tbody>
<tr>
<td><a href="/books/2">A Wizard of Earthsea</a></td>
<td>Ursula K. Le Guin</td>
<td>1968-11-01</td>
</tr>
<tr>
<td><a href="/books/3">Ficciones</a></td>
<td>Jorge Luis Borges</td>
<td>1944-01-01</td>
</tr>
<tr>
<td><a href="/books/1">The Dispossessed</a></td>
<td>Ursula K. Le Guin</td>
<td>1974-05-01</td>
</tr>
</tbody>
</table>
</body>
</html>
Notice the rendering of th:href="@{/books/{id}(id=${book.id})}": the path variable is filled in, the static href was replaced,
and the empty-state row was dropped because its th:if was false. A missing book returns the status set by the
ResponseStatusException:
$ curl -s -o /dev/null -w '%{http_code}\n' -u reader:change-me localhost:8080/books/99
404
A first test
A web-layer test with MockMvc renders the real template, so it catches a wrong view name or a broken expression without a
browser. In Boot 4 the annotation lives in the org.springframework.boot.webmvc.test.autoconfigure package (supplied by
spring-boot-starter-webmvc-test):
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("local")
class BookControllerTest {
@Autowired
MockMvc mvc;
@Test
@WithMockUser
void listShowsSeededBooksSortedByTitle() throws Exception {
mvc.perform(get("/books"))
.andExpect(status().isOk())
.andExpect(view().name("books/list"))
.andExpect(content().string(containsString("Ficciones")));
}
@Test
void helloIsPublic() throws Exception {
mvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Hello, Bookshelf!")));
}
@Test
void booksRequireLogin() throws Exception {
mvc.perform(get("/books")).andExpect(status().is3xxRedirection());
}
}
./mvnw test runs the three tests plus the context-load test generated by Initializr: four tests, all green. The Testing page covers the Thymeleaf-specific testing options in depth, and
Unit & Integration Testing covers the Spring Boot side.
The official example applications
The Thymeleaf project ships runnable examples in its repository, under examples/ on the 3.1-master branch. They are the
reference implementations of the three official tutorials, and each is worth running once:
| Example | What it shows | Tutorial section |
|---|---|---|
GTVG, Good Thymes Virtual Grocery ( |
Thymeleaf without Spring: one servlet filter that builds the template engine and dispatches each URL to a small controller class. The best place to see the engine API, resolvers and contexts directly. |
Using Thymeleaf, section 2 (also the standalone page of this section) |
STSM, Spring Thyme Seed Starter Manager ( |
A complete Spring MVC form application: a business layer, MVC configuration, a controller with |
Thymeleaf + Spring, section 4 |
extraThyme ( |
A custom dialect on top of STSM: a CSS-class attribute processor, an element processor and a model processor. |
Extending Thymeleaf, section 3 |
The same directory also holds smaller applications: sayhello, springmail and thvsjsp under spring6, Boot examples such
as biglist-mvc, biglist-webflux, features-mvc and sse-webflux under springboot3, and the Spring Security samples
under springsecurity5 and springsecurity6. Clone the repository and build one with its own Maven wrapper or the parent
pom.xml of examples/.
The examples are useful and not up to date in every respect. The Boot examples live in springboot2 and springboot3; there
is no springboot4 directory, so they use Boot 3 package names and starters (for instance spring-boot-starter-web, not
spring-boot-starter-webmvc) and the older test annotations. The Spring examples use the thymeleaf-spring6 module, which is
still the right module for Spring Framework 7. Read them for the Thymeleaf usage and the structure, and take the Boot
coordinates from Initializr.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
The view name does not match a file under |
Check the view name against the path ( |
The page shows the literal text |
The class is a |
Use |
An edit to a template has no effect |
The template cache is on, or the changed file was not copied to |
Use the |
|
Spring Security protects every URL by default; a form login needs a session cookie. |
Authenticate (HTTP Basic here, for tooling), or permit the URL on purpose. Do not disable security to make a demo work. |
|
|
Load what the page needs: |
Templates re-parsed on every request in production |
|
Keep it in |
Static link |
The URL was written as a plain |
Write |
|
Templates are not served by URL; only a controller can render them. |
Add a controller mapping, or move the file to |
Application starts but no controller is found |
The |
Keep the application class in the root package of the project ( |
Relying on DevTools LiveReload to refresh the browser |
LiveReload is deprecated as of Boot 4.1.0 with no replacement and is only active with |
Refresh manually with the cache off, or use a front-end dev server. |
Recent changes and where the books are outdated
The two books consulted for this section predate several changes that touch a first project. Taming Thymeleaf (the consulted PDF is v1.1.1, 2021; Leanpub now sells an edition updated for Spring Boot 3) builds on Java 11, Spring Boot 2.3 and Thymeleaf 3.0; Modern frontends with htmx (v1.0.0, 2023) uses Spring Boot 3.1.4 and Thymeleaf 3.1. Compared with them:
-
Platform. Java 21 or later, Spring Boot 4.1 (Spring Framework 7, Jakarta EE 11,
jakarta.*imports) and Thymeleaf 3.1.5 replacejavax.*, Boot 2.3 and Thymeleaf 3.0. Thymeleaf 3.1.5 is the minimum version to use: 3.1.4 and 3.1.5 fixed expression-sandbox vulnerabilities (CVE-2026-40477, CVE-2026-40478 and CVE-2026-41901) that allow server-side template injection when untrusted text is evaluated as an expression. The Template Security & XSS page covers them. -
Boot 4 artifacts. The web starter is
spring-boot-starter-webmvc(notspring-boot-starter-web), the H2 console is its own module (spring-boot-h2console), each technology has its own*-teststarter (spring-boot-starter-thymeleaf-test,spring-boot-starter-webmvc-test), and test annotations such as@AutoConfigureMockMvcmoved to new packages. Thymeleaf’s auto-configuration now lives in thespring-boot-thymeleafmodule. Code copied from a Boot 3 tutorial may not compile. -
Reloading. The book workflow (Gulp and browser-sync proxies, DevTools LiveReload) has aged: LiveReload is deprecated in Boot 4.1, and
spring.thymeleaf.cache=falsein alocalprofile remains the supported baseline, as the book also teaches. -
Persistence. The book’s early chapters use PostgreSQL started with Docker Compose, value-object identifiers, Flyway and Testcontainers. All of that still works and is documented on the persistence pages linked above; note that Spring Data 3 changed the repository hierarchy (
PagingAndSortingRepositoryno longer extendsCrudRepository) and thatjakarta.persistencereplacesjavax.persistence. -
Version coordinates. Never copy a Thymeleaf version into a Boot build. The Boot BOM manages Thymeleaf, the Layout Dialect and the data-attribute dialect; only third-party add-ons such as
htmx-spring-boot(5.2.0 on 2026-10-07; a 6.0.0 for htmx 4 is documented but unreleased) need explicit versions. -
Examples. The official example projects in the Thymeleaf repository stop at Boot 3 (
springboot3), as noted above.