Spring MVC Integration

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-08, and the examples were compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, MockMvc and a plain Spring MVC context without Boot); the rendered output shown is the real output of those runs. The page applies to Thymeleaf with Spring MVC (the servlet stack). The Spring dialect and the SpEL expressions it enables are shared with Spring WebFlux, but the view classes shown here are the servlet ones; WebFlux has its own reactive view resolver (see the Spring Boot Auto-configuration & Properties page). Thymeleaf standalone has no controllers and no view resolvers; see Standalone Engine & Configuration.

Thymeleaf was designed as a template engine, not as a web framework, and Spring MVC already decides which handler runs, which model it fills and which view name it returns. The integration is therefore the thin layer that lets a Spring MVC ViewResolver hand a view name to a Thymeleaf TemplateEngine, and that lets the templates use Spring’s own facilities (SpEL, MessageSource, conversion, validation results, RequestDataValueProcessor). This page covers that layer and the controller conventions that feed it; forms, validation, conversion and internationalization have their own pages. The running scenario is the Bookshelf introduced on the Getting Started page.

What thymeleaf-spring6 provides

Thymeleaf ships its Spring integration as separate artifacts per Spring generation: thymeleaf-spring5 and thymeleaf-spring6. There is no thymeleaf-spring7: Spring Framework 7 applications use org.thymeleaf:thymeleaf-spring6 3.1.5.RELEASE, which is also what the Spring Boot 4.1 BOM manages. The tutorial states the migration rule for older code in one sentence: replace the org.thymeleaf.spring6 package with org.thymeleaf.spring5 if the application still runs on Spring 5.

The integration lets Spring MVC controllers forward to Thymeleaf templates exactly as they would to JSPs, replaces OGNL with the Spring Expression Language (SpEL), connects forms to form-backing beans and binding results, shows messages from the Spring MessageSource, and resolves templates through Spring’s resource abstraction. In a Boot application the starter brings everything:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

The SpringStandard dialect

The dialect that does the work is org.thymeleaf.spring6.dialect.SpringStandardDialect, a subclass of org.thymeleaf.standard.StandardDialect. Everything from the Standard Dialect is inherited (the th:* attributes, the five expression types), and the Spring dialect adds:

  • SpEL as the expression language: every ${...} and *{...} expression is evaluated by Spring’s engine instead of OGNL, which gives safe navigation (?.), ${@beanName.method()} access to any bean of the application context and the SpEL compiler

  • the form attributes th:field, th:errors and th:errorclass, and a th:object that selects a form command object (see the the Forms & Data Binding page)

  • the expression object #themes (the counterpart of the spring:theme JSP tag) and #mvc with its url(...) method (the counterpart of spring:mvcUrl(...), see Link URLs)

  • th:method, which lets a form declare PUT, PATCH or DELETE (see Hidden HTTP methods below)

The tutorial advises against registering this dialect on a plain TemplateEngine. Use org.thymeleaf.spring6.SpringTemplateEngine, which applies the dialect and wires Spring’s MessageSource in for you.

The engine, the resolver and the view resolver

Three beans connect Spring MVC to Thymeleaf, from the template up to the web layer: a template resolver finds the template file, the template engine parses and processes it, and a view resolver turns a controller’s view name into a view that calls the engine.

flowchart LR ctl["Controller returns view name"] --> vr["ThymeleafViewResolver"] vr --> view["ThymeleafView"] view --> eng["SpringTemplateEngine
SpringStandardDialect"] eng --> res["SpringResourceTemplateResolver
prefix, suffix, mode, cache"] res --> file["classpath:/templates/books.html"]

A manual configuration (no Spring Boot)

Without Boot you declare the three beans yourself. The resolver is the Spring variant, SpringResourceTemplateResolver, which uses Spring’s own Resource loading (so a prefix may be classpath:/templates/, file:... or a servlet-context path) and needs the ApplicationContext.

@Configuration
@EnableWebMvc
class WebConfig implements ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Bean
    SpringResourceTemplateResolver templateResolver() {
        SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(applicationContext);
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");
        resolver.setTemplateMode(TemplateMode.HTML);   // HTML is the default
        resolver.setCharacterEncoding("UTF-8");
        resolver.setCacheable(true);                   // false while developing
        return resolver;
    }

    @Bean
    SpringTemplateEngine templateEngine() {
        SpringTemplateEngine engine = new SpringTemplateEngine();
        engine.setTemplateResolver(templateResolver());
        engine.setEnableSpringELCompiler(true);        // see the SpEL compiler below
        return engine;
    }

    @Bean
    ThymeleafViewResolver viewResolver() {
        ThymeleafViewResolver resolver = new ThymeleafViewResolver();
        resolver.setTemplateEngine(templateEngine());
        resolver.setCharacterEncoding("UTF-8");
        resolver.setOrder(1);
        resolver.setViewNames(new String[] {"manual/*"});
        return resolver;
    }
}

The configuration was exercised in a plain Spring MVC web context built with AnnotationConfigWebApplicationContext and MockMvc, with a controller that returns the view name manual/hello:

<p th:text="'Hello, ' + ${name}">Hello</p>
<p>Hello, Bookshelf</p>

The test also asserted that engine.getEnableSpringELCompiler() returns true. The prefix and suffix live on the template resolver, never on the view resolver; that is the main difference from a JSP InternalResourceViewResolver.

What Spring Boot configures

In a Boot application none of the three beans is written by hand. ThymeleafAutoConfiguration (module spring-boot-thymeleaf in Boot 4) registers a SpringResourceTemplateResolver, a SpringTemplateEngine and a ThymeleafViewResolver from the spring.thymeleaf.* properties, and each is backed off when you declare your own (the view resolver is conditional on a bean named thymeleafViewResolver). The default view resolver gets the order Ordered.LOWEST_PRECEDENCE - 5, so that other resolvers, such as Boot’s BeanNameViewResolver, are consulted first. The property-to-bean mapping was verified with a test:

spring.thymeleaf.enable-spring-el-compiler=true
spring.thymeleaf.excluded-view-names=legacy/*
@SpringBootTest(properties = {
        "spring.thymeleaf.enable-spring-el-compiler=true",
        "spring.thymeleaf.excluded-view-names=legacy/*"})
class BootViewResolverTest {

    @Autowired SpringTemplateEngine engine;
    @Autowired @Qualifier("thymeleafViewResolver") ThymeleafViewResolver resolver;

    @Test
    void propertiesReachTheBeans() {
        assertThat(engine.getEnableSpringELCompiler()).isTrue();
        assertThat(resolver.getExcludedViewNames()).containsExactly("legacy/*");
        assertThat(resolver.getOrder()).isEqualTo(Integer.MAX_VALUE - 5);
    }
}

Printing the same values gave:

order=2147483642 excluded=[legacy/*] dialects=[SpringStandardDialect, BookshelfDialect, HtmxDialect, SpringSecurityDialect, LayoutDialect]

The dialect list is that of the scratch application: SpringStandardDialect is always first, and every IDialect bean (the Layout Dialect, the Spring Security dialect, the htmx dialect and the application’s own dialect) is added by the auto-configuration. The complete property table, including the cache, encoding, template-check and reactive keys, is on Spring Boot Auto-configuration & Properties.

The SpEL compiler

SpEL can compile frequently evaluated expressions to bytecode. Thymeleaf exposes the switch as SpringTemplateEngine.setEnableSpringELCompiler(boolean), and Boot as spring.thymeleaf.enable-spring-el-compiler. It defaults to false, as the tutorial explains, because compilation can break templates in which the same expression text is evaluated against objects of different types (for example a fragment reused with a Book in one place and a Magazine in another). Turn it on for hot, type-stable pages, measure, and keep the exception in mind: a compiled expression that meets a different type fails at run time and not at parse time. The complete caching and performance discussion is on the Performance & Production page.

Views and view resolvers

Spring MVC’s templating core is two interfaces, org.springframework.web.servlet.View and org.springframework.web.servlet.ViewResolver. A View renders a page (usually by running a template engine); a ViewResolver maps the view name returned by a controller, plus the locale, to a View. The resolvers form an ordered chain, and the first one that can resolve the name wins. Thymeleaf supplies one class for each side:

  • org.thymeleaf.spring6.view.ThymeleafView

  • org.thymeleaf.spring6.view.ThymeleafViewResolver

Most pages do not need a View bean: when no bean has the view name, the resolver creates a ThymeleafView on the fly for each name it resolves.

ThymeleafViewResolver properties

Setter / Boot property Meaning

setOrder(int)

Position in the resolver chain. Lower values are asked first. Boot uses Ordered.LOWEST_PRECEDENCE - 5 (a ThymeleafViewResolver accepts any name, so it must be late)

setViewNames(String[]) / spring.thymeleaf.view-names

Patterns (with * wildcards) of the view names this resolver may handle; a name that does not match is passed to the next resolver. The tutorial’s example uses .html and .xhtml patterns

setExcludedViewNames(String[]) / spring.thymeleaf.excluded-view-names

Patterns it must not handle, for names that another resolver (a JSP resolver, a PDF view) should serve

setTemplateEngine(ISpringTemplateEngine)

The engine that renders the views; required

setCharacterEncoding(String), setContentType(String), setForceContentType(boolean)

Response encoding and content type (Boot: spring.thymeleaf.encoding and spring.thymeleaf.servlet.content-type)

setProducePartialOutputWhileProcessing(boolean)

Whether output is flushed while the template is still being processed (Boot: spring.thymeleaf.servlet.produce-partial-output-while-processing, default true)

setRedirectContextRelative(boolean), setRedirectHttp10Compatible(boolean)

How a redirect: view name starting with / is interpreted (see below)

setAlwaysProcessRedirectAndForward(boolean)

Whether redirect: and forward: names are handled even when viewNames does not match them

setViewClass(Class) and addStaticVariable(String, Object)

The View implementation to instantiate (default ThymeleafView), and variables added to every view it creates

Redirect and forward view names

A view name that starts with redirect: or forward: is not a template name. The ThymeleafViewResolver recognizes the two prefixes (they are public constants, REDIRECT_URL_PREFIX and FORWARD_URL_PREFIX) and returns Spring’s RedirectView or an InternalResourceView instead of a ThymeleafView. The path after a redirect: prefix that starts with / is relative to the application’s context path, unless setRedirectContextRelative(false) was called. The two behave very differently for the browser:

  • redirect:/mvc/books answers 302 Found with a Location header, so the browser issues a second request and its address bar changes

  • forward:/mvc/books is handled inside the server by the servlet container: one request, one response, the URL does not change, and the request attributes are kept

@GetMapping("/home")
String home() { return "redirect:/mvc/books"; }

@GetMapping("/catalog")
String catalog() { return "forward:/mvc/books"; }
dump("home", mvc.perform(get("/mvc/home")).andExpect(status().isFound()).andExpect(redirectedUrl("/mvc/books")).andReturn());
dump("catalog", mvc.perform(get("/mvc/catalog")).andExpect(forwardedUrl("/mvc/books")).andReturn());
GET /mvc/home     -> status=302 location=/mvc/books
GET /mvc/catalog  -> status=200 forwardedUrl=/mvc/books   (the container performs the forward)

Another check confirmed that /mvc/home produced a Location without a query string, although the request had model attributes (appName, activeMenu) at the time. Do not rely on that when you add your own attributes to a redirecting request: pass what the target needs explicitly, as flash attributes or as redirectAttributes.addAttribute(...) (which become query parameters).

A ThymeleafView bean with static variables

When one page needs a special configuration, you can declare its ThymeleafView as a prototype bean. The tutorial adds static variables this way, which become available as ordinary context variables to the template:

@Bean
@Scope("prototype")
ThymeleafView mainView() {
    ThymeleafView view = new ThymeleafView("mvc/main");   // the template name
    view.setStaticVariables(Collections.singletonMap("footer", "Bookshelf Ltd."));
    return view;
}

@GetMapping("/main")
String main() { return "mainView"; }                       // a View bean name, not a template name
<main>Main page</main>
<footer th:text="${footer}">footer</footer>
<main>Main page</main>
<footer>Bookshelf Ltd.</footer>

The tutorial’s recipe works as printed with a single ThymeleafViewResolver. In a Boot application it does not: Boot registers a BeanNameViewResolver that comes first in the chain and returns the raw ThymeleafView bean without giving it the template engine or the locale, so the first request fails with IllegalArgumentException: Property 'locale' is required. The working variant used above is a second ThymeleafViewResolver that runs earlier and claims only that name:

@Bean
ThymeleafViewResolver viewBeanResolver(SpringTemplateEngine templateEngine) {
    ThymeleafViewResolver resolver = new ThymeleafViewResolver();
    resolver.setTemplateEngine(templateEngine);
    resolver.setCharacterEncoding("UTF-8");
    resolver.setOrder(1);
    resolver.setViewNames(new String[] {"mainView"});
    return resolver;
}

If all you need is a footer text on every page, a @ModelAttribute method (next section) is simpler than a view bean; reserve view beans for the rare page that needs its own markupSelector or a custom View subclass. Declaring a second ThymeleafViewResolver also means that autowiring a single one by type fails with NoUniqueBeanDefinitionException, as one of the scratch tests found; inject by name or qualifier.

Controllers, models and model attributes

A Thymeleaf template never talks to the controller. It receives a context, a map of variables, and Spring MVC’s model becomes that map: every attribute in the Model (or ModelAndView, or ModelMap) is a Thymeleaf context variable of the same name. Spring calls them model attributes; the Thymeleaf term is context variables. Request-mapping annotations and argument resolution are Spring’s business and the Spring Framework reference is the authority; this section shows the parts that matter for templates.

Mapping, model and return types

@Controller
@RequestMapping("/mvc")
public class MvcController {

    private final ShelfService shelf;

    public MvcController(ShelfService shelf) { this.shelf = shelf; }

    @GetMapping("/books")
    String list(Model model, BookForm bookForm) {
        model.addAttribute("books", shelf.findAll());
        return "mvc/books";
    }

    @GetMapping("/books/{id}")
    ModelAndView detail(@PathVariable long id, @RequestParam(defaultValue = "false") boolean full) {
        BookInfo book = shelf.find(id);
        if (book == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        ModelAndView mav = new ModelAndView("mvc/book");
        mav.addObject("book", book);
        mav.addObject("full", full);
        return mav;
    }
    // ...
}
<h1 th:text="${book.title}">Title</h1>
<p th:text="${book.author}">Author</p>
<p th:if="${full}">Full details requested</p>

Requesting /mvc/books/1?full=true rendered:

<h1>Dune</h1>
<p>Frank Herbert</p>
<p>Full details requested</p>

Requesting /mvc/books/99 answered 404 through the ResponseStatusException. The pieces:

  • @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping map one HTTP method each; @RequestMapping on the class adds a path prefix. Two methods that map the same method and path make the application fail at startup with an IllegalStateException about an ambiguous mapping, so a typo cannot silently shadow a page

  • a String return value is a view name; a ModelAndView carries the name and the model together; a Model or ModelMap argument is how a String-returning method fills the model

  • @PathVariable binds a segment of the path (/books/{id}) and @RequestParam a query or form parameter, converted to the parameter type by the conversion service (see the Conversion & Formatting page)

  • a form object as an argument (the BookForm bookForm in list(...)) is added to the model under its decapitalized class name, which is what th:object="${bookForm}" binds to

A handler may also return a view name computed from request input, for example return "pages/" + page;. Do not do this. The view name decides which template Thymeleaf resolves, so request-controlled names let a visitor render templates you never meant to expose, and a name such as pages/x :: secret also selects fragments. Map fixed paths to fixed names, or look the value up in a whitelist (see the Template Security & XSS page for the written-up attack).

Model attributes shared by many handlers

A method annotated with @ModelAttribute runs before every handler method of its controller and adds its return value to the model. This is the idiom for lists a page always needs (a drop-down of genres, the authors) and for marking the active menu item:

// Runs before every handler method of this controller; the value is a constant, never request input
@ModelAttribute("activeMenu")
String activeMenu() { return "books"; }
<nav>
  <a href="books.html" th:href="@{/mvc/books}" th:classappend="${activeMenu == 'books'} ? 'active'">Books</a>
</nav>
<nav>
  <a href="/mvc/books" class="active">Books</a>
</nav>

The same annotation in a @ControllerAdvice class is global: it applies to the handlers of every controller the advice covers. Without arguments the advice covers all controllers; the selectors basePackageClasses, basePackages, assignableTypes and annotations narrow it. The Bookshelf advice below is limited to the controllers of one package:

@ControllerAdvice(basePackageClasses = MvcController.class)
public class MvcAdvice {

    @ModelAttribute("appName")
    String appName() { return "Bookshelf"; }
}
<head><title th:text="${appName}">Bookshelf</title></head>

The test asserted model().attribute("appName", "Bookshelf") and model().attribute("activeMenu", "books"), and the rendered <head> was <head><title>Bookshelf</title></head>. The Thymeleaf and Spring documents both treat this as the sanctioned way to expose common data; the other two are Model.addAttribute and ModelAndView.addObject.

Post/Redirect/Get

A browser that receives HTML as the answer to a POST and is then reloaded asks the user whether to submit the form again. The cure is the Post/Redirect/Get pattern: the POST handler does its work and answers with a redirect, and the browser then loads the result with an ordinary GET. Reloading repeats only the GET. A POST with validation errors is the exception: it must not redirect, because the binding result with the errors would be lost; it renders the form again with status 200.

sequenceDiagram participant B as Browser participant C as Controller participant T as Template B->>C: POST /mvc/books (title, author, _csrf) alt valid C->>C: save the book, store the flash message C-->>B: 302 Location /mvc/books B->>C: GET /mvc/books C->>T: render books T-->>B: 200 list with the flash message once else binding errors C->>T: render books again (BindingResult in the model) T-->>B: 200 form with field errors, no redirect end
@PostMapping("/books")
String create(@Valid BookForm bookForm, BindingResult result, Model model, RedirectAttributes redirect) {
    if (result.hasErrors()) {
        model.addAttribute("books", shelf.findAll());
        return "mvc/books";                          // 200, no redirect: the errors must survive
    }
    shelf.add(bookForm.getTitle(), bookForm.getAuthor());
    redirect.addFlashAttribute("message", "Book saved");
    return "redirect:/mvc/books";                    // 302: the browser then issues a GET
}
<p th:if="${message}" th:text="${message}">Flash message</p>

RedirectAttributes.addFlashAttribute keeps the value in the session just long enough for the redirected request, then removes it; the template reads it as a normal model attribute. The test drove the whole round trip with MockMvc:

MvcResult post = mvc.perform(post("/mvc/books").with(csrf()).param("title", "Ulysses").param("author", "James Joyce"))
        .andExpect(status().isFound()).andExpect(redirectedUrl("/mvc/books")).andReturn();
MvcResult get = mvc.perform(get("/mvc/books").flashAttrs(post.getFlashMap())).andExpect(status().isOk())
        .andExpect(content().string(containsString("Book saved"))).andReturn();
// a refresh of the GET shows no flash message and does not add a second book
mvc.perform(get("/mvc/books")).andExpect(content().string(not(containsString("Book saved"))));
POST /mvc/books  -> status=302 location=/mvc/books flash={message=Book saved}
GET  /mvc/books  -> status=200, body contains <p>Book saved</p> and the new Ulysses entry
GET  /mvc/books  -> status=200, no flash message

The invalid submission (title empty) answered 200 with the view mvc/books and the message of the @NotBlank constraint next to the field:

<input type="text" id="title" name="title" value="">
<span>must not be blank</span>
<input type="text" id="author" name="author" value="">

Flash messages and error pages are developed further on the Error Handling & Flash Messages page, and the form itself on the Forms & Data Binding page.

Hidden HTTP methods

An HTML form can only submit GET or POST. REST-style controllers use PUT, PATCH and DELETE, and Spring bridges the gap with HiddenHttpMethodFilter, which reads a request parameter named _method from a POST and wraps the request as if it had that method. Thymeleaf’s side is th:method: when its value is a method other than get or post, such as delete, the processor writes method="post" and adds the hidden _method field. In Boot the filter is off by default:

spring.mvc.hiddenmethod.filter.enabled=true
@DeleteMapping("/books/{id}")
String delete(@PathVariable long id, RedirectAttributes redirect) {
    shelf.remove(id);
    redirect.addFlashAttribute("message", "Book deleted");
    return "redirect:/mvc/books";
}
<form th:action="@{/mvc/books/{id}(id=${b.id})}" th:method="delete">
  <button type="submit">Delete</button>
</form>

The rendered form (the CSRF token is shortened here):

<form action="/mvc/books/1" method="post"><input type="hidden" name="_csrf" value="6wbaCJ1m..."/><input type="hidden" name="_method" value="delete"/>
  <button type="submit">Delete</button>
</form>

Two details deserve attention. First, the _csrf field appears because the form uses th:action: Thymeleaf asks Spring’s RequestDataValueProcessor for extra hidden fields, and Spring Security’s implementation contributes the CSRF token. A form written with a plain action attribute would get no token and Spring Security would reject the submission with 403. Second, the state change is a POST carrying a CSRF token, never a GET link, which is why the test requests below behave as shown:

mvc.perform(post("/mvc/books/2").with(csrf()).param("_method", "delete"))
        .andExpect(status().isFound()).andExpect(redirectedUrl("/mvc/books"));
mvc.perform(post("/mvc/books/2").param("_method", "delete")).andExpect(status().isForbidden());
POST /mvc/books/2 _method=delete + CSRF token -> 302 Location /mvc/books, flash {message=Book deleted}
POST /mvc/books/2 _method=delete, no token    -> 403
POST /mvc/books/2 _method=delete, filter off  -> 405, HttpRequestMethodNotSupportedException: Request method 'POST' is not supported

The last row comes from a run with spring.mvc.hiddenmethod.filter.enabled=false. The filter accepts the methods PUT, DELETE and PATCH. An alternative that needs no filter is a dedicated @PostMapping("/books/{id}/delete"); both are fine. When JavaScript chooses the form action (for example a confirmation dialog), include the CSRF token in the form yourself; the Spring Security Integration page shows how.

Request, session and application data

The tutorial section on STSM shows the model route; the thymeleaf.org article Spring MVC and Thymeleaf: how to access data from templates lists the rest. All of it is read with ${...}; the dialect decides what each name means:

Expression Reads

${books}

A model attribute (a context variable); this includes request attributes

${param.q}

The request parameter q; an empty value if it is not present. A multi-valued parameter is read with an index, ${param.q[0]}

${session.lastSearch}

An HttpSession attribute; ${session == null} is true when no session exists

${application.catalogVersion}

A ServletContext attribute, shared by all requests and sessions

${@shelfService.count()}

A Spring bean by name, a SpEL feature

@GetMapping("/data")
String data(HttpSession session, Model model) {
    session.setAttribute("lastSearch", "dune");
    model.addAttribute("total", shelf.count());
    return "mvc/data";
}
<p id="model" th:text="${appName} + ': ' + ${total} + ' books'">model</p>
<p id="param" th:text="${param.q}">request parameter</p>
<p id="params" th:if="${param.tag != null}" th:text="${param.tag[0] + ' / ' + param.tag[1]}">several values</p>
<p id="session" th:text="${session.lastSearch}">session attribute</p>
<p id="app" th:text="${application.catalogVersion}">servlet context attribute</p>
<p id="bean" th:text="${@shelfService.count()}">bean call</p>

The servlet context attribute catalogVersion was set by the configuration class through ServletContextAware. Requesting /mvc/data?q=Thymeleaf+is+great&tag=a&tag=b produced:

<p id="model">Bookshelf: 2 books</p>
<p id="param">Thymeleaf is great</p>
<p id="params">a / b</p>
<p id="session">dune</p>
<p id="app">2026.10</p>
<p id="bean">2</p>

Version 3.1 removed the old expression objects #request, #response, #session and #servletContext, because the engine no longer assumes the servlet API: the article above still shows param, session and application, which remain supported, and these cover the cases the removed objects were used for. If a value of the request is needed for the page, add it to the model in the controller (for example the current path) instead of reaching into the request from the template. The utility objects and the removal are discussed on Expression Utility Objects and Standard Expression Syntax.

Beans are reachable from every template, which is powerful and a reason for discipline: expose a small, read-only bean for the template (a ShelfService.count() style query), never a repository or a service with side effects. Restricted mode forbids some expression forms in places where user input could reach them; see the Template Security & XSS page.

The Spring Thyme Seed Starter Manager

The thymeleaf.org tutorial builds its Spring chapters around a sample application, the Spring Thyme Seed Starter Manager (STSM): one page that lists seed starters and a form to add new ones, with a SeedStarterMngController whose @ModelAttribute methods populate the drop-downs and whose two mapped methods show the form (GET) and process it (POST, then redirect:). Its Spring MVC configuration class declares exactly the three beans described above, a ResourceBundleMessageSource, and two formatters registered through WebMvcConfigurer.addFormatters. The same controller pattern, a GET that shows the form and a POST that validates, saves and redirects, is what the Bookshelf controller above follows, and the form, validation and conversion chapters of the tutorial are covered on the Forms, Validation and Conversion pages of this section. The application is on GitHub as thymeleafexamples-stsm (a Spring 5 and a Spring 6 version); like the tutorial it uses a hand-written @EnableWebMvc configuration, whereas a Boot application replaces that class with the starter and the properties shown earlier.

It is still a prototype

The tutorial’s section 8 makes the point of the whole engine: the STSM template still opens in a browser from the file system, because every th:* attribute is added to markup that already works. The Bookshelf templates above do the same; for instance <a href="books.html" th:href="@{/mvc/books}" ...> keeps a clickable link in the static prototype. Only the Spring-specific attributes (th:field, th:errors) show their placeholder values in a prototype, and the page on Natural Templates & Decoupled Logic shows how to keep those natural as well.

Spring WebFlow (legacy)

The Spring integration packages include an adapter for Spring WebFlow, the older XML-driven conversation framework. It is a legacy note here: new applications use plain controllers, redirects and flash attributes (for the AJAX-style partial updates it was used for, see the Fragment Rendering & AJAX page). It matters only when an existing WebFlow application is being upgraded. The tutorial states that Spring 6 needs Spring WebFlow 3.0 or later (and Spring 5 needs 2.5), and that the integration replaces the view resolver:

@Bean
public ViewResolver thymeleafViewResolver() {
    AjaxThymeleafViewResolver viewResolver = new AjaxThymeleafViewResolver();
    // A special ThymeleafView implementation is needed
    viewResolver.setViewClass(FlowAjaxThymeleafView.class);
    viewResolver.setTemplateEngine(templateEngine());
    return viewResolver;
}

AjaxThymeleafViewResolver and FlowAjaxThymeleafView live in the package org.thymeleaf.spring6.webflow.view; the other beans (flow registry and executor, FlowBuilderServices, an MvcViewFactoryCreator that receives the view resolver) are configured as the Spring WebFlow reference describes, and a view-state then names a Thymeleaf template. The AJAX fragments of a transition are listed in a <render fragments="..."/> element and must be elements that carry an id. This snippet is the tutorial’s, quoted from its description; it was not run, because the scratch application has no WebFlow dependency.

Pitfalls

Symptom Cause Fix

IllegalArgumentException: Property 'locale' is required for a ThymeleafView bean

In Boot, BeanNameViewResolver returned the view bean before ThymeleafViewResolver could initialize it

Add an earlier ThymeleafViewResolver limited to that name (see above), or use a @ModelAttribute method instead of a view bean

HttpRequestMethodNotSupportedException: Request method 'POST' is not supported on a th:method="delete" form

HiddenHttpMethodFilter is not registered (off by default in Boot)

Set spring.mvc.hiddenmethod.filter.enabled=true

403 after submitting a form

The form used action instead of th:action, so no CSRF token was added, or JavaScript replaced the form

Use th:action; include _csrf yourself when scripts build the form

The same form is submitted again when the page is reloaded

The POST handler returned a view instead of redirecting

Post/Redirect/Get: return redirect:... after success (and render the form again only when binding fails)

The flash message never appears

It was added with addAttribute or the redirect target is not a handler that renders the page

Use addFlashAttribute and read it as ${message} on the page the redirect lands on

A template under /WEB-INF or another folder renders for a crafted URL

The controller builds the view name from request data

Return fixed view names, or look the value up in a whitelist

NoUniqueBeanDefinitionException for ThymeleafViewResolver

A second resolver bean was declared and another bean injects the type

Inject with @Qualifier, or avoid a second resolver

#request, #session or #servletContext are rejected

The objects were removed in Thymeleaf 3.1

Use param, session, application, or add the value to the model

A compiled SpEL expression fails with a ClassCastException or an unexpected type

enable-spring-el-compiler=true and the same expression evaluated for different types

Turn the compiler off for that application, or keep each expression’s types stable

Changes to a template do not show up

spring.thymeleaf.cache is true (the default); or the DevTools restart was not triggered

Set spring.thymeleaf.cache=false in development only

Version notes

  • The Spring dialect and view classes live in org.thymeleaf.spring6 for Spring 6 and 7; the Thymeleaf + Spring tutorial for 3.1 states that its examples use Spring 6.x and thymeleaf-spring6, and replaces nothing for Spring 7 (there is no thymeleaf-spring7).

  • In Boot 4 the Thymeleaf auto-configuration moved to the spring-boot-thymeleaf module, with the package org.springframework.boot.thymeleaf.autoconfigure. The MockMvc test support moved too (org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc).

  • spring.thymeleaf.enabled has been deprecated at error level since Boot 4.0.0 and no longer disables the auto-configuration; depend on thymeleaf-spring6 directly if you do not want the auto-configuration.

  • th:include and the unwrapped fragment syntax are deprecated in 3.1, and th:substituteby was removed; the #request, #response, #session and #servletContext objects were removed; see the What’s Changed & Migration page.

  • The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) teaches controllers, Post/Redirect/Get, HiddenHttpMethodFilter and the active menu item with Spring Boot 2.3, Java 11 and Thymeleaf 3.0; the ideas are unchanged, but use jakarta.* imports, spring-boot-starter-webmvc and the packages listed above.