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:

flowchart LR browser["Browser
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-thymeleaf brings Thymeleaf and its Spring integration (thymeleaf-spring6, which is also the module used with Spring Framework 7: there is no thymeleaf-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) and thymeleaf-extras-data-attribute (2.0.1).

  • thymeleaf-extras-springsecurity6 is added by Initializr when Spring Security and Thymeleaf are both chosen; it provides the sec:* attributes and Boot registers its dialect automatically.

  • spring-boot-starter-thymeleaf-test is the test starter introduced with the modularised Boot 4 test support; with spring-boot-starter-webmvc-test it supplies MockMvc and 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 default spring.thymeleaf.prefix=classpath:/templates/ and spring.thymeleaf.suffix=.html, the view name books/list is the file templates/books/list.html. Sub-folders are part of the view name.

  • static/ (also public/, resources/ and META-INF/resources/) is served directly at the root of the application, so static/css/app.css is /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.html does not render templates/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.

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-devtools on the classpath, Boot sets development-friendly defaults, including spring.thymeleaf.cache=false, spring.web.resources.cache.period=0, spring.web.resources.chain.cache=false and spring.h2.console.enabled=true, and makes the error page include the message, binding errors and stack trace. The log says Devtools property defaults active!. Set spring.devtools.add-properties=false to turn the defaults off.

  • An explicit property. spring.thymeleaf.cache=false does the same without DevTools. Put it in a development-only profile (next section), never in the base application.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/resources and /META-INF/maven do not trigger a restart (configurable with spring.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 (examples/core/thymeleaf-examples-gtvg-jakarta, with a javax twin)

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 (examples/spring6/thymeleaf-examples-spring6-stsm; Boot variants springboot3-stsm-mvc and springboot3-stsm-webflux)

A complete Spring MVC form application: a business layer, MVC configuration, a controller with @ModelAttribute methods, forms, validation and message bundles.

Thymeleaf + Spring, section 4

extraThyme (examples/spring6/thymeleaf-examples-spring6-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

TemplateInputException: Error resolving template [books/list] (HTTP 500)

The view name does not match a file under templates/, the folder is in the wrong place (src/main/webapp, src/main/java), or the IDE put the output after another classpath entry.

Check the view name against the path (books/list is templates/books/list.html), keep templates in src/main/resources/templates, and run from Maven or reorder the IDE classpath.

The page shows the literal text hello instead of HTML

The class is a @RestController, or the method has @ResponseBody, so the returned view name is written as the body.

Use @Controller for pages; reserve @RestController for JSON endpoints.

An edit to a template has no effect

The template cache is on, or the changed file was not copied to target/classes / build/resources/main.

Use the local profile (cache off), rebuild resources from the IDE or with process-resources, and hard refresh the browser for static files.

/books redirects to /login, and curl receives a login page

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.

LazyInitializationException rendering ${book.author.name}

spring.jpa.open-in-view=false is set and the author was a lazy proxy not loaded by the controller’s query.

Load what the page needs: @EntityGraph / fetch join as above, or map to a view object (the display page of this section).

Templates re-parsed on every request in production

spring.thymeleaf.cache=false was committed to application.properties, or a profile with it was activated.

Keep it in application-local.properties; check the active profiles at startup.

Static link /css/app.css breaks under a context path

The URL was written as a plain href or src instead of a @{...} URL.

Write th:href="@{/css/app.css}" and keep the relative path in href for the prototype.

/hello.html returns 404 (or the login page when security covers it)

Templates are not served by URL; only a controller can render them.

Add a controller mapping, or move the file to static/ if it is plain HTML.

Application starts but no controller is found

The @SpringBootApplication class is in a different package than the controllers, so component scanning misses them.

Keep the application class in the root package of the project (com.example.bookshelf).

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 spring.devtools.livereload.enabled=true.

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 replace javax.*, 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 (not spring-boot-starter-web), the H2 console is its own module (spring-boot-h2console), each technology has its own *-test starter (spring-boot-starter-thymeleaf-test, spring-boot-starter-webmvc-test), and test annotations such as @AutoConfigureMockMvc moved to new packages. Thymeleaf’s auto-configuration now lives in the spring-boot-thymeleaf module. 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=false in a local profile 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 (PagingAndSortingRepository no longer extends CrudRepository) and that jakarta.persistence replaces javax.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.