Spring Boot
View Resolver
Learn how Spring MVC ViewResolver maps view names to templates: Thymeleaf, JSP, content negotiation, resolver order and fixes for common errors.
What ViewResolver Does
When a @Controller method returns a String, Spring MVC treats it as a logical view name and gives it to the ViewResolver chain. Each resolver tries to turn the name into a View object, and the first one that returns a View wins. The View then renders the response, for example by writing HTML to the HttpServletResponse.
In a REST API built with @RestController, ViewResolver is never used. @ResponseBody makes Spring write the return value straight to the response through an HttpMessageConverter, so there is no view resolution. ViewResolver matters only for server-side rendering (SSR) with Thymeleaf, FreeMarker, JSP and similar.
Two useful details. A controller method can return void, and Spring then derives the view name from the request URL. Names starting with redirect: or forward: are handled specially and skip template rendering. After a successful form POST, prefer redirect: so a browser refresh does not submit the form again (the Post/Redirect/Get pattern).
Java
// āā @Controller + view name ā ViewResolver is invoked āāāāāāāāāāāāāāāāā
@Controller
public class UserPageController {
@GetMapping("/users")
public String usersPage(Model model) {
model.addAttribute("users", userService.findAll());
return "users/list"; // logical view name ā resolved to a template
}
}
// āā @RestController + @ResponseBody ā ViewResolver is NOT invoked āāāāāā
@RestController
public class UserApiController {
@GetMapping("/api/users")
public List<UserResponse> findAll() {
return userService.findAll(); // serialised to JSON ā no view resolution
}
}
// āā Explicit redirect and forward ā special view name prefixes āāāāāāāāā
@Controller
public class RedirectController {
@PostMapping("/users")
public String create(@ModelAttribute CreateUserRequest request) {
userService.create(request);
return "redirect:/users"; // 302 redirect ā no template rendered
}
@GetMapping("/legacy")
public String forward() {
return "forward:/users"; // server-side forward ā URL in browser unchanged
}
}Thymeleaf Setup (Maven)
Thymeleaf is the recommended template engine for Spring Boot server-side rendering. Adding spring-boot-starter-thymeleaf auto-configures a ThymeleafViewResolver, a template engine and sensible defaults. You do not need any Java configuration to start rendering templates. For Gradle, the equivalent is implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'.
XML
<!-- pom.xml ā add Thymeleaf starter: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Needs a web starter as well, if not already present: -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>Thymeleaf ViewResolver Configuration
By default templates live in src/main/resources/templates/ and have a .html extension, so a controller returning "users/list" is resolved to classpath:/templates/users/list.html. You only need properties to change these defaults.
Note on caching: spring.thymeleaf.cache defaults to true, and that is what you want in production. Set it to false in development so template edits show up on refresh, although Spring Boot DevTools already does that for you. The error folder is special: Spring Boot's error handling looks for templates such as error/404.html, error/5xx.html or error/error.html and renders them for the matching status code.
yaml
# āā Defaults, shown explicitly (application.yml): āāāāāāāāāāāāāāāāāāāāāā
spring:
thymeleaf:
prefix: classpath:/templates/ # template root directory
suffix: .html # file extension appended to view name
mode: HTML # template mode: HTML, XML, TEXT, JAVASCRIPT
encoding: UTF-8
cache: false # false in dev; keep true (default) in production
check-template-location: true # fail fast if template directory is missing
# āā View name resolution: āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# Controller returns "users/list"
# ā ThymeleafViewResolver resolves to:
# ā classpath:/templates/users/list.html
# āā File layout: āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# src/main/resources/
# āāā templates/
# āāā layout.html ā shared layout fragment
# āāā users/
# ā āāā list.html ā resolved by "users/list"
# ā āāā detail.html ā resolved by "users/detail"
# ā āāā form.html ā resolved by "users/form"
# āāā error/
# āāā 404.html ā rendered for HTTP 404
# āāā 5xx.html ā rendered for any 5xx errorThymeleaf Template Basics
Thymeleaf templates are valid HTML files with th:* attributes that bind model data. The engine processes those attributes on the server and removes them, so the browser receives clean HTML. Because templates stay valid HTML, designers can open them directly in a browser and see the placeholder text.
Two things to remember. th:text escapes HTML, which protects you from XSS, while th:utext does not, so use th:utext only for content you fully trust. Also, th:insert keeps the host tag and puts the fragment inside it, while th:replace swaps the host tag for the fragment itself. Most layouts use th:replace.
html
<!-- src/main/resources/templates/users/list.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Users</title>
</head>
<body>
<!-- th:text ā set element text content from model attribute (HTML-escaped): -->
<h1 th:text="${pageTitle}">Users</h1>
<!-- th:each ā iterate over a collection: -->
<table>
<tr th:each="user : ${users}">
<td th:text="${user.name}">Name</td>
<td th:text="${user.email}">Email</td>
<!-- th:href ā build a URL (adds the context path automatically): -->
<td><a th:href="@{/users/{id}(id=${user.id})}">View</a></td>
</tr>
</table>
<!-- th:if / th:unless ā conditional rendering: -->
<p th:if="${users.empty}">No users found.</p>
<!-- th:href with query param: -->
<a th:href="@{/users(page=${currentPage + 1})}">Next</a>
<!-- Include a fragment defined in layout.html: -->
<div th:replace="~{layout :: footer}"></div>
</body>
</html>
<!-- src/main/resources/templates/layout.html ā defines the fragment: -->
<footer th:fragment="footer">
<p>Ā© MyApp</p>
</footer>
<!-- th:insert ā <div> stays, footer goes inside it -->
<!-- th:replace ā <div> is replaced by the footer element -->Passing Data to the View
A view needs data from the controller. The usual ways are a Model parameter, a ModelAndView return value, and RedirectAttributes when you redirect. Model attributes live for one request, so after a redirect they are gone. RedirectAttributes.addFlashAttribute stores them in the session just long enough to survive one redirect, which is the standard way to show a "saved successfully" message after Post/Redirect/Get.
Java
@Controller
@RequestMapping("/users")
public class UserPageController {
// āā Model parameter ā the most common style: āāāāāāāāāāāāāāāāāāāāā
@GetMapping
public String list(Model model) {
model.addAttribute("users", userService.findAll());
model.addAttribute("pageTitle", "All Users");
return "users/list";
}
// āā ModelAndView ā view name and data in one object: āāāāāāāāāāāāā
@GetMapping("/{id}")
public ModelAndView detail(@PathVariable Long id) {
ModelAndView mav = new ModelAndView("users/detail");
mav.addObject("user", userService.findById(id));
return mav;
}
// āā Redirect with a one-time flash message: āāāāāāāāāāāāāāāāāāāāāā
@PostMapping
public String create(@ModelAttribute CreateUserRequest request,
RedirectAttributes redirectAttributes) {
userService.create(request);
redirectAttributes.addFlashAttribute("message", "User created");
return "redirect:/users";
}
}
// In list.html, show the flash message if present:
// <div th:if="${message}" th:text="${message}"></div>InternalResourceViewResolver (JSP) ā Dependencies
InternalResourceViewResolver resolves view names to JSP files. It is the legacy default for Spring MVC applications without a modern template engine. Spring Boot supports JSP only with WAR packaging. JSPs are not supported in an executable JAR, which is the main reason JSP is not recommended for new projects.
Use the default scope for tomcat-embed-jasper if you run the executable WAR with java -jar. Use provided only when you deploy the WAR to an external Tomcat that already has Jasper. Place your JSP files in src/main/webapp/WEB-INF/views/ so the prefix below finds them. You also need a JSTL implementation if your JSPs use JSTL tags.
XML
<!-- pom.xml ā packaging must be war for JSP: -->
<packaging>war</packaging>
<!-- JSP compiler for embedded Tomcat (use provided only for external Tomcat): -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<!-- JSTL API and implementation (needed if JSPs use JSTL tags): -->
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
</dependency>InternalResourceViewResolver (JSP) ā Configuration
With the two properties below Spring Boot configures an InternalResourceViewResolver for you, so you do not need to declare the bean yourself. Because that resolver builds a path from any name it is given, it must always be the last resolver in the chain, as the priority section explains.
yaml
# āā application.yml: āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
spring:
mvc:
view:
prefix: /WEB-INF/views/
suffix: .jsp
# āā View name resolution: āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# Controller returns "users/list"
# ā InternalResourceViewResolver resolves to:
# ā /WEB-INF/views/users/list.jsp
# āā Where the files go (Maven WAR project): āāāāāāāāāāāāāāāāāāāāāāāāāāā
# src/main/webapp/
# āāā WEB-INF/
# āāā views/
# āāā users/
# āāā list.jspContentNegotiatingViewResolver
ContentNegotiatingViewResolver chooses a view based on the requested media type (the Accept header, or a request parameter if enabled). Spring Boot always auto-configures one, with the highest precedence, and it wraps the other ViewResolver beans in your application. It collects those resolvers by itself, so you normally do not call setViewResolvers.
Be careful about what it does not do. By default it adds no JSON or XML view, so an Accept: application/json request to a template-based method does not return JSON on its own. For JSON endpoints use @RestController or @ResponseBody. Use default views only if you really want one controller method to serve both HTML and a rendered alternative.
Java
// āā What Spring Boot auto-configures (conceptually): āāāāāāāāāāāāāāāāāāā
// ContentNegotiatingViewResolver (order: HIGHEST_PRECEDENCE)
// āāā delegates to every other ViewResolver bean:
// ThymeleafViewResolver, BeanNameViewResolver, ...
// It picks the View whose content type matches the Accept header.
// Accept: text/html ā ThymeleafViewResolver ā users/detail.html
// āā Adding a default JSON view for non-HTML requests: āāāāāāāāāāāāāāāāā
@Configuration
public class ViewConfig {
@Bean
public ContentNegotiatingViewResolver contentNegotiatingViewResolver() {
ContentNegotiatingViewResolver cnvr = new ContentNegotiatingViewResolver();
// Other ViewResolver beans are detected automatically.
cnvr.setDefaultViews(List.of(new MappingJackson2JsonView()));
cnvr.setOrder(Ordered.HIGHEST_PRECEDENCE);
return cnvr;
}
}
// āā Setting a default content type in code (no property exists for it): ā
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.defaultContentType(MediaType.TEXT_HTML);
}
}Content Negotiation Properties
Content negotiation by file extension (for example /users.json) was removed in Spring Framework 6, which Spring Boot 3 uses, so there is no property for it any more. If you still need to choose the format from the URL, enable the request parameter strategy.
yaml
# āā application.yml: āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
spring:
mvc:
contentnegotiation:
favor-parameter: true # enables /users?format=json (disabled by default)
parameter-name: format # name of the request parameter (default: format)
media-types:
json: application/json
xml: application/xmlViewResolver Priority and Chaining
Multiple ViewResolvers are asked in order, and the first one that returns a View wins. The order value works like a priority number: a lower number runs first, and Ordered.LOWEST_PRECEDENCE (Integer.MAX_VALUE) runs last.
An important pitfall is that some resolvers claim every view name. ThymeleafViewResolver does not check that the template file exists, and InternalResourceViewResolver never returns null. If either one comes before another resolver that should handle a name, that name never reaches the second resolver. ThymeleafViewResolver runs at LOWEST_PRECEDENCE - 5, which is late but still before InternalResourceViewResolver, so in an app that uses both, JSP views are never reached unless you limit Thymeleaf with spring.thymeleaf.view-names, for example thymeleaf/*. Mixing Thymeleaf and JSP in one application is rare, and one technology is usually the better choice.
Spring Boot already registers a BeanNameViewResolver at LOWEST_PRECEDENCE - 10, which runs before Thymeleaf. You only need to declare your own if you want a different order. This resolver is what makes a controller return a View bean by its bean name.
Java
@Configuration
public class ViewResolverConfig {
// āā Order values (lower number = asked first): āāāāāāāāāāāāāāāāāāāā
// ContentNegotiatingViewResolver HIGHEST_PRECEDENCE (auto-configured)
// BeanNameViewResolver LOWEST_PRECEDENCE - 10 (auto-configured)
// ThymeleafViewResolver LOWEST_PRECEDENCE - 5 (auto-configured)
// InternalResourceViewResolver LOWEST_PRECEDENCE (always last)
// āā BeanNameViewResolver ā resolves view name to a View bean: āāāāā
// Boot already provides this; declare it only to change its order.
@Bean
public BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10); // before Thymeleaf
return resolver;
}
// āā Named view bean ā resolved by BeanNameViewResolver: āāāāāāāāāāā
// Needs Apache POI on the classpath (poi and poi-ooxml) for AbstractXlsView.
@Bean("usersExcelView")
public View usersExcelView() {
return new AbstractXlsView() {
@Override
protected void buildExcelDocument(Map<String, Object> model,
Workbook workbook, HttpServletRequest request,
HttpServletResponse response) {
Sheet sheet = workbook.createSheet("Users");
@SuppressWarnings("unchecked")
List<UserResponse> users = (List<UserResponse>) model.get("users");
// ... populate spreadsheet
}
};
}
// Controller returns "usersExcelView" ā BeanNameViewResolver matches it
// before Thymeleaf attempts to find a .html template.
// āā InternalResourceViewResolver ā always last: āāāāāāāāāāāāāāāāāāāā
// It never returns null ā it always builds a resource path, even if the
// file does not exist ā so it must have the lowest priority.
@Bean
public InternalResourceViewResolver jspResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
resolver.setOrder(Ordered.LOWEST_PRECEDENCE); // always last
return resolver;
}
}Common View Resolver Problems
Most view problems come down to a handful of causes. Match the symptom you see with the cause below.
Java
// āā 1. Controller returns a String but the browser shows plain text āāā
// Cause: the class is a @RestController (or the method has @ResponseBody),
// so the string is written to the response body, not resolved as a view.
// Fix: use @Controller for pages, @RestController only for APIs.
// āā 2. TemplateInputException: Error resolving template [users/list] āā
// Cause: Thymeleaf cannot find templates/users/list.html.
// Fix: check spelling, folder and case (case matters on Linux),
// and confirm the file is under src/main/resources/templates.
// āā 3. "Circular view path [users]: would dispatch back to the current
// handler URL [/users] again" (JSP / InternalResourceViewResolver) ā
// Cause: view name equals the request path, so forward goes back to the controller.
// Fix: return a different view name, or use Thymeleaf (not affected).
// āā 4. Whitelabel Error Page instead of your template āāāāāāāāāāāāāāāāā
// Cause: no template resolver matched, or the error template is missing.
// Fix: add templates/error/404.html and templates/error/5xx.html.
// āā 5. Template changes do not appear until restart āāāāāāāāāāāāāāāāāāā
// Cause: template caching is on (the default).
// Fix: spring.thymeleaf.cache=false in development, or add DevTools.
// āā 6. Special characters or HTML show as raw text āāāāāāāāāāāāāāāāāāāā
// Cause: th:text escapes HTML on purpose.
// Fix: use th:utext only for trusted content.