Spring MVC with JSP
|
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 Spring Framework 7.0.9 and Spring Boot 4.1.1, which sit on the Jakarta EE 11 baseline
(Servlet 6.1, so Tomcat 11 or Jetty 12.1), together with Jakarta Pages 4.0, Jakarta Expression Language 6.0, Jakarta Tags
3.0 (API 3.0.2, implementation 3.0.1), Hibernate Validator 9.1.4.Final and Java 21. Everything was verified on 2026-10-08:
the rules against the Spring Framework reference chapter "JSP and JSTL", the Javadoc of the classes named here, the tag
library descriptors spring.tld and spring-form.tld read from the spring-webmvc 7.0.9 jar and the Spring Boot
reference, and every example by building it into a Maven WAR (context paths /g7spring, /g7springbean and
/g7springboot) and running it on Apache Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones those
runs produced. Spring MVC does not render a page itself: a controller returns a view name, a view resolver turns it into
a view object, and for JSP the view forwards the request to the JSP container, which is the same
RequestDispatcher.forward that a hand-written Model 2 servlet uses (see the servlet
fundamentals page). What Spring adds is the controller side (data binding, validation, message sources, URL mapping), a
view resolver that finds the page, and two tag libraries that read what the framework put in the request. This page covers
that glue: registering the resolver, the JSTL bridge, every tag of the spring: and form: libraries, HTTP method
conversion, and Spring Boot’s rules for JSP, which are narrower than the Spring Framework’s. The running scenario is the
Bookshelf catalogue, here in a Spring MVC variant of the book list and the book form of the pages before this one. The
page ends with a worked example, a pitfalls table, what changed in Spring 6 and 7 and where the books in the section
bibliography are out of date. The framework-neutral Model 2 pattern (a servlet controller, JSP views under WEB-INF) is
written on the MVC with Servlets & JSP page of this section, the form, validation and session mechanics of plain servlets
on the Forms, Validation & Sessions page, the output-escaping contexts on the Security page and the Jasper settings on the
Jasper Configuration & Precompilation page; none of them is repeated here.
Status and Where It Runs
JSP is supported by Spring MVC and will keep being supported, but it is not the view technology the Spring team recommends:
the Spring Boot reference says "If possible, JSPs should be avoided" and lists FreeMarker, Groovy templates, Thymeleaf and
Mustache as the engines it auto-configures. The reasons are in Boot’s JSP Limitations (executable jars cannot serve JSPs,
a custom error.jsp does not replace the default error view, the source directory must be the standard one) and are covered
in the Spring Boot section below. Where an application already has JSPs, or where a container such as Tomcat
is the deployment target anyway, Spring MVC with JSP is a complete and current combination.
| Item | Value on 2026-10-08 | Notes |
|---|---|---|
Spring Framework |
7.0.9 (7.1.0-M2 is a milestone) |
Servlet 6.1 baseline: Tomcat 11.0 or Jetty 12.1; Jakarta EE 11; JDK 17 minimum |
Spring Boot |
4.1.1 (4.2.0-M2 is a milestone) |
Manages Spring Framework 7.0.9; its dependency tree manages Tomcat 11.0.24 for the embedded server |
View resolver for JSP |
|
Registered by |
View class |
|
|
Spring tag libraries |
|
Both are inside |
Jakarta Tags |
3.0 ( |
The |
Servlet container |
Tomcat 11.0.26 for these runs |
Spring 7 needs Servlet 6.1, so Tomcat 10.1 and Jetty 12.0 are not supported targets |
The page does not repeat what the Spring Framework reference teaches about controllers, @RequestMapping, data binding or
validation in general; for those, and for Spring Boot basics, see the Spring Boot
getting started page, and for a template engine that renders the same controllers see the
Thymeleaf Spring MVC integration page.
The Bookshelf Used on This Page
The scratch application is a Maven WAR with no web.xml. The controller, the configuration class, the
WebApplicationInitializer and two JSPs under WEB-INF/views are the whole of it. The dependencies are Spring Web MVC
from the Spring Framework BOM, the Servlet and Pages APIs as provided (Tomcat supplies them), the Jakarta Tags API and
implementation, which Tomcat does not ship, and Hibernate Validator for Bean Validation.
<properties>
<maven.compiler.release>21</maven.compiler.release>
<maven.compiler.parameters>true</maven.compiler.parameters>
<failOnMissingWebXml>false</failOnMissingWebXml>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-framework-bom</artifactId>
<version>7.0.9</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp</groupId>
<artifactId>jakarta.servlet.jsp-api</artifactId>
<version>4.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.4.Final</version>
</dependency>
</dependencies>
Source: Spring Framework — JSP and JSTL
The property maven.compiler.parameters passes -parameters to javac. Spring MVC reads controller parameter names from
the bytecode, and without that flag a method such as delete(@PathVariable long id) fails at the first request (the
pitfalls table shows the message). The Spring Boot parent POM sets the flag itself, which is why the Boot
variant below does not need the property.
package org.example.bookshelf;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class Book {
private long id;
@NotBlank
@Size(max = 80)
private String title;
@NotBlank
private String author;
@Min(1450)
private int year;
private String genre = "NOVEL";
private boolean available = true;
private java.util.List<String> tags = new java.util.ArrayList<>();
// standard getters and setters; isAvailable() for the boolean
}
A form-backing object has to be a JavaBean with a default constructor and setters, because the form tags read and the data
binder writes properties by name. The immutable record used for the read-only pages earlier in this section is therefore
kept for display and a mutable bean is used for the form.
How a Request Reaches a JSP
The steps are the ones the Spring reference describes for any view technology; only the last three are specific to JSP.
JstlView copies the model into request attributes (so ${books} finds the list the controller added) and, before the
forward, stores the current Locale and Spring’s MessageSource where JSTL’s fmt: tags look for them. The forward goes
through RequestDispatcher, which is why a JSP under WEB-INF is reachable by the controller and not by the browser:
a request for /g7spring/WEB-INF/views/books.jsp returns 404 (observed). The container’s own JSP servlet then runs the
translation, compilation and request phases described on the lifecycle
and translation page, with the request and session scopes of the
implicit objects and scopes page.
| Participant | Role for JSP |
|---|---|
|
The front controller, mapped to |
Handler ( |
Returns the logical view name and fills the |
|
Maps a logical name to a view; |
|
Exposes the model as request attributes and forwards to the resource |
Jasper |
Compiles and runs the page; it knows nothing about Spring |
View Resolvers
The Spring reference says: "When developing with JSPs, you typically declare an InternalResourceViewResolver bean", that
it can dispatch to any servlet resource "but in particular for JSPs", and that "as a best practice, we strongly encourage
placing your JSP files in a directory under the WEB-INF directory so there can be no direct access by clients."
registry.jsp()
In the Java configuration the resolver is registered through the ViewResolverRegistry that WebMvcConfigurer hands to
configureViewResolvers. The no-argument form registers a JSP resolver with the default view name prefix /WEB-INF/ and
the default suffix .jsp; the two-argument form sets both.
package org.example.bookshelf;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableWebMvc
@ComponentScan("org.example.bookshelf")
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/views/", ".jsp");
}
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource source = new ResourceBundleMessageSource();
source.setBasename("messages");
source.setDefaultEncoding("UTF-8");
return source;
}
}
With the shorter registry.jsp() the books page would be looked up at /WEB-INF/books.jsp. The Bookshelf keeps its views in
WEB-INF/views, so the first run with the no-argument call produced exactly the failure the defaults predict:
HTTP/1.1 404
JSP file [/WEB-INF/books.jsp] not found
Source: ViewResolverRegistry.jsp()
Calling jsp() more than once registers one resolver per call. Because an InternalResourceViewResolver cannot tell
whether a JSP exists without forwarding to it, several JSP resolvers only make sense together with the viewNames
property, which says which view names each one handles (for example admin/* under one prefix and everything else under
another). The Spring reference chains resolvers with Ordered, and the Javadoc repeats the rule that the internal resource resolver
must be last.
InternalResourceViewResolver as a bean
The registry is shorthand. Declaring the bean yourself gives access to every property of the resolver, such as
viewClass, requestContextAttribute and viewNames. The variant below was built as a second WAR with
registry.jsp(...) removed.
@Bean
public InternalResourceViewResolver jspViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setViewClass(JstlView.class);
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
resolver.setRequestContextAttribute("requestContext");
return resolver;
}
With requestContextAttribute set, every JSP can read a Spring RequestContext; a line ctx=${requestContext.contextPath}
locale=${requestContext.locale} in the page printed ctx=/g7springbean locale=en_US. The context object also exposes the
message source and the bind status, which the tags below use internally.
| Property of the resolver | Meaning | Default |
|---|---|---|
|
Added around the logical name to form the resource path |
Empty strings for the bean; |
|
The view class for every resolved name |
|
|
Request attribute that exposes the Spring |
not set |
|
Patterns of the names this resolver handles (for several resolvers in one chain) |
every name |
|
Position in the resolver chain |
lowest priority; keep it last |
|
Include instead of forward; expose beans of the application context as request attributes |
off |
The redirect: and forward: prefixes are handled by the resolver base class, not by the prefix and suffix: a controller
that returns redirect:/books produces an HTTP redirect (the Bookshelf’s create and delete methods do, and curl showed
302 with Location: /g7spring/books), and forward:/other forwards to another path inside the application. A view name that
starts with either prefix never reaches the JSP folder.
The XML form
The reference still shows the XML namespace form, <mvc:view-resolvers><mvc:jsp/></mvc:view-resolvers> inside a
beans element. Spring Framework 7.0 deprecated the whole mvc: XML namespace in favour of the Java configuration: "There are
no plans yet for removing it completely, but XML configuration will not be updated to follow the Java configuration
model", while the generic bean namespace is not deprecated. New code uses WebMvcConfigurer; the XML form only matters
for old applications.
Starting the application without web.xml
On Servlet 3.0 and later containers the Spring SpringServletContainerInitializer finds every
WebApplicationInitializer, so a class is enough to register the dispatcher servlet and filters; the WAR has no
deployment descriptor. This initializer also registers the filter of the HTTP method conversion section.
package org.example.bookshelf;
import jakarta.servlet.Filter;
import org.springframework.web.filter.HiddenHttpMethodFilter;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
public class BookshelfInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return null;
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[] { WebConfiguration.class };
}
@Override
protected String[] getServletMappings() {
return new String[] { "/" };
}
@Override
protected Filter[] getServletFilters() {
return new Filter[] { new HiddenHttpMethodFilter() };
}
}
The mapping / makes the dispatcher the default servlet of the application. Tomcat’s own JSP servlet is mapped to
*.jsp and *.jspx in the container’s conf/web.xml, and those more specific mappings still win, so a JSP that is
requested directly (which only works outside WEB-INF) is served by Jasper, not by Spring. That is one more
reason for the WEB-INF rule.
JSP versus JSTL: JstlView
The Spring reference puts it in one sentence: when using the JSP Standard Tag Library you must use a special view class,
JstlView, "as JSTL needs some preparation before things such as the I18N features can work". Two things make that
preparation unnoticeable in practice: InternalResourceViewResolver selects JstlView by itself when the JSTL API is on
the classpath (the Javadoc: "The default is InternalResourceView, or JstlView if the JSTL API is present"), and the
bridge uses the same MessageSource as the Spring tags. The page tags.jsp printed the same text through both libraries:
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<p>spring:message : <spring:message code="books.title"/></p>
<p>fmt:message : <fmt:message key="books.title"/></p>
spring:message : Bookshelf catalogue
fmt:message : Bookshelf catalogue
With Accept-Language: es and a messages_es.properties containing books.title=Catálogo de la Librería both lines printed
Catálogo de la Librería, and the count line Showing {0} books became Mostrando 2 libros. No fmt:setBundle and no
jakarta.servlet.jsp.jstl.fmt.localizationContext context parameter were involved: the view exposed Spring’s message
source and locale to JSTL. The locale itself is chosen by Spring’s LocaleResolver (the Accept-Language header by default),
not by the JSTL rules of the JSTL i18n and messages page; an application
that mixes fmt:setLocale with Spring’s resolver sees two sources of truth, so one of them should be chosen.
Source: JstlView Javadoc
The messages file used for these runs, which both libraries and the validation messages of the form share:
books.title=Bookshelf catalogue
book.new=New book
book.field.title=Title
book.field.author=Author
book.field.year=Year
book.field.genre=Genre
book.field.available=Available
book.save=Save
book.delete=Delete
book.count=Showing {0} books
NotBlank.book.title=The title is required
NotBlank.book.author=The author is required
Min.book.year=The year must be 1450 or later
One cosmetic point of the MessageFormat syntax: the first page printed "Showing 1 books" after a deletion, because
{0} is substituted and not pluralised. A choice format in the message (see the formatting rules on the JSTL i18n page)
fixes it, and the Spring tag does not change the rule.
The spring: Tag Library
The library is declared with its URI; the descriptor spring.tld (library version 4.0, short name spring) is inside
spring-webmvc-7.0.9.jar, so no taglib mapping in web.xml and no extra dependency are needed.
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
The reference states that "all Spring tags have HTML escaping features to enable or disable escaping of characters". The descriptor of 7.0.9 contains eleven tags:
| Tag | Purpose | Attributes (* required) |
|---|---|---|
|
Resolves a message code against the |
|
|
Supplies one argument in the body of |
|
|
Builds a URL: adds the context path, fills |
|
|
A request or template parameter for the enclosing |
|
|
Evaluates a Spring Expression Language (SpEL) expression and prints or stores the result |
|
|
Escapes its body for HTML and/or JavaScript |
|
|
Sets the default HTML escaping for the rest of the page |
|
|
Exposes a |
|
|
Prefixes every nested |
|
|
Evaluates its body only if the named object has binding errors, and exposes |
|
|
Applies the property editor of a bound property to another value |
|
Two things are absent from the 7.0.9 descriptors, which matters for pages written for older Spring versions: there is no
spring:theme tag, and form:form has no commandName attribute (see the changes section).
spring:message, spring:argument and the message codes
The tag resolves code through the application’s MessageSource for the locale chosen by the LocaleResolver. Arguments
for the {0} placeholders come from the arguments attribute (several values separated by argumentSeparator, a comma
by default) or from nested spring:argument tags; text is used when the code does not exist, and var stores the result
instead of printing it. Observed on Tomcat 11.0.26:
<p>missing code : <spring:message code="no.such.code" text="(default text)"/></p>
<p>with var : <spring:message var="count" code="book.count" arguments="${books.size()}"/>${count}</p>
<spring:message var="m" code="book.count"><spring:argument value="${book.id}"/></spring:message>
message with argument tag: ${m}
missing code : (default text)
with var : Showing 2 books
message with argument tag: Showing 1 books
A code with no message in any bundle and no text fails the page: <spring:message code="zzz.missing"/> produced a
JspTagException with the message No message found under code 'zzz.missing' for locale 'en_US'. as the root cause of the
JasperException; a default text is the usual protection for codes that are still being written.
spring:url and spring:param
spring:url prepends the context path to a context-relative value (so the page does not hard-code /g7spring), expands
{id} templates from spring:param and URL-encodes the values; extra parameters become the query string. The tag is the
Spring counterpart of the c:url tag of the URLs, imports and
redirects page, with URI-template support.
<p>url relative : <spring:url value="/books/new"/></p>
<p>url with param : <spring:url value="/books"><spring:param name="q" value="Don Quixote & co"/></spring:url></p>
<p>url template : <spring:url value="/books/{id}"><spring:param name="id" value="${book.id}"/></spring:url></p>
url relative : /g7spring/books/new
url with param : /g7spring/books?q=Don%20Quixote%20%26%20co
url template : /g7spring/books/1
On the very first request of a browser that has no session cookie yet, Tomcat’s URL rewriting appends the session id to
every URL that goes through response.encodeURL, and spring:url is one of them: the first response contained
/g7spring/books/1;jsessionid= followed by the session id, the second request (with the cookie) did not. The JSTL c:url tag
encodes URLs through the same encodeURL call. The cure is a tracking-mode of COOKIE in web.xml or in ServletContext.setSessionTrackingModes; the Boot
property server.servlet.session.tracking-modes configures only the embedded server and had no effect in the deployed WAR
(;jsessionid was still present).
spring:eval
spring:eval evaluates a SpEL expression against the "standard JSP evaluation context consisting of implicit variables and
scoped attributes" (Javadoc), so that book.title.toUpperCase() reads the request attribute book and calls a method.
<p>eval title : <spring:eval expression="book.title.toUpperCase()"/></p>
<p>eval static : <spring:eval expression="T(java.lang.Math).max(1,2)"/></p>
eval title : DON QUIXOTE
eval static : 2
Source: EvalTag Javadoc
SpEL is a full expression language: it calls methods and constructors, and T(...) reaches any class on the class path.
That is the whole of the injection caveat. The expression attribute accepts a runtime expression, so an author can write
<spring:eval expression="${param.expr}"/>, which first evaluates the JSP expression ${param.expr} and then evaluates the
resulting string as SpEL: a double evaluation. A request to /g7spring/books/tags?expr=T(java.lang.Math).abs(-5) printed
5; the demonstration used a harmless method, but the same request line could name any class and any method the
application can load. An empty expr produced the message 'expressionString' must not be null or blank as the root cause of a
JasperException. The rule is the one of every expression language: never build an expression from request data, and never
pass untrusted text as the expression attribute. The general EL-injection discussion, the OWASP guidance and the Tomcat
settings that limit EL belong to the Security page of this section.
The same kind of double evaluation existed in the Spring tags themselves: CVE-2011-2730 (verified as published on
cve.org on 2026-10-08) covers Spring Framework before 2.5.6.SEC03, 2.5.7.SR023 and 3.x before 3.0.6, where a container with EL
support evaluated the attributes of spring:message, spring:bind, spring:nestedPath, spring:hasBindErrors,
spring:theme and spring:transform twice, which let an attacker obtain sensitive information. The vulnerability was fixed
long ago and does not affect Spring 7; it is the history behind the warning.
Source: CVE-2011-2730
HTML escaping: what is escaped by default
The reference says that the tags have escaping features; it is worth knowing the defaults. The page tags.jsp printed the
same body through the tags with the default, with an explicit htmlEscape="true", and after spring:htmlEscape switched the
page default on:
<p>eval raw : <spring:eval expression="book.title + '<i>x</i>'"/></p>
<p>eval escaped : <spring:eval expression="book.title + '<i>x</i>'" htmlEscape="true"/></p>
<p>message text : <spring:message code="no.such" text="<b>bold</b>"/></p>
<p>message escaped: <spring:message code="no.such" text="<b>bold</b>" htmlEscape="true"/></p>
<spring:htmlEscape defaultHtmlEscape="true"/>
<p>after page default on, eval : <spring:eval expression="book.title + '<i>x</i>'"/></p>
<p>after page default on, msg : <spring:message code="no.such" text="<b>bold</b>"/></p>
<p>after, explicit off : <spring:message code="no.such" text="<b>bold</b>" htmlEscape="false"/></p>
eval raw : Don Quixote<i>x</i>
eval escaped : Don Quixote<i>x</i>
message text : <b>bold</b>
message escaped: <b>bold</b>
after page default on, eval : Don Quixote<i>x</i>
after page default on, msg : <b>bold</b>
after, explicit off : <b>bold</b>
The defaults of the generic tags (spring:message, spring:eval, spring:url, spring:bind) are therefore no escaping,
exactly like ${...} on a JSP, unless something turns it on: the attribute on the tag, spring:htmlEscape for the rest of
the page (which overrides a defaultHtmlEscape context parameter in web.xml, as the descriptor says) or that context
parameter for the whole application. The form tags are different: form:input rendered a title typed as
"><script>alert(1)</script> as value=""><script>alert(1)</script>" (observed), because they always escape bound values. The
safe habit is the one of plain JSP pages: print data with c:out or ${fn:escapeXml(x)} (see the
JSTL core page), and put the defaultHtmlEscape context parameter on, so that a
forgotten attribute does not turn a message into markup. spring:escapeBody escapes a block of body text, and
javaScriptEscape is for text inside a script string, not a replacement for a JavaScript-context encoder.
spring:bind, spring:nestedPath, spring:hasBindErrors and spring:transform
These tags expose the binding status to a page that does not use the form library. spring:bind sets a status variable
for a path; spring:nestedPath prefixes the paths of the binds inside it; spring:hasBindErrors runs its body only when
the named object has errors and exposes the errors object; spring:transform pushes a value through the property editor
of a bound property. Observed output of a nested bind and of the error summary:
<spring:nestedPath path="book">
<spring:bind path="title">
status.expression=${status.expression} value=${status.value} errors=${status.error}
</spring:bind>
</spring:nestedPath>
<spring:hasBindErrors name="book">
<p class="summary">${errors.errorCount} problem(s)</p>
</spring:hasBindErrors>
status.expression=title value=Don Quixote errors=false
<p class="summary">1 problem(s)</p>
(The second output is the summary printed for a submission with an empty title; the first is the GET /books/bind page.) With
the form library the same information is available through form:errors, and the bind tags are rarely needed in new
pages.
The Form Tag Library
The reference says that "Spring provides a comprehensive set of data binding-aware tags for handling form elements" and that
the form library, unlike other form libraries, "is integrated with Spring Web MVC, giving the tags access to the command
object and reference data your controller deals with". The descriptor is spring-form.tld (library version 4.3, short name
form), bundled in spring-webmvc.
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
The tags of the library, as the 7.0.9 descriptor lists them:
| Tag | What it renders | Library-specific attributes (besides the HTML ones) |
|---|---|---|
|
An HTML |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
One checkbox plus a marker field |
|
|
A checkbox per item of a list, array or map |
|
|
One radio button |
|
|
A radio button per item |
|
|
A |
|
|
One option, selected when it equals the bound value |
|
|
An option per item |
|
|
Field or object errors in a |
|
|
A |
|
|
A |
|
Almost every tag also accepts the common HTML attributes (id, cssClass, cssStyle, lang, title, dir, tabindex,
disabled and the on* event attributes), cssErrorClass (the CSS class used instead of cssClass when the field has an
error) and dynamic attributes, which is how HTML5 attributes such as placeholder, required or data-* pass through. The
reference, under HTML5 Tags, says the same: the library "allows entering dynamic attributes, which means you can enter any
HTML5 specific attributes", and form:input accepts any type (the reference adds that type='text' is not required, since text is
the default). The reference also says that the generated HTML is HTML 4.01 / XHTML 1.0 compliant; in practice the tags emit
self-closing <input .../> markup that every HTML5 parser accepts.
form:form, the command object and modelAttribute
form:form renders the form element and "puts the command object in the PageContext so that the command object can be
accessed by inner tags". Without a modelAttribute it looks for a model attribute called command; naming the attribute
(the reference calls that "definitely a best practice") binds the form to a model attribute of that name. The controller
that shows the empty form and the one that handles the submission:
package org.example.bookshelf;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/books")
public class BookController {
private final BookRepository repository;
public BookController(BookRepository repository) {
this.repository = repository;
}
@ModelAttribute("genres")
public List<String> genres() {
return List.of("NOVEL", "HORROR", "POETRY");
}
@GetMapping
public String list(Model model) {
model.addAttribute("books", repository.findAll());
return "books";
}
@GetMapping("/new")
public String newBook(Model model) {
model.addAttribute("book", new Book());
return "book-form";
}
@PostMapping
public String create(@Valid @ModelAttribute("book") Book book, BindingResult result) {
if (result.hasErrors()) {
return "book-form";
}
repository.save(book);
return "redirect:/books";
}
@DeleteMapping("/{id}")
public String delete(@PathVariable long id) {
repository.delete(id);
return "redirect:/books";
}
}
Source: Spring Framework — The form tag
The BindingResult parameter must directly follow the @Valid object; without it Spring would throw
MethodArgumentNotValidException and the user would see an error page instead of the form with its messages. Returning
"book-form" (not a redirect) keeps the model and the binding result in the request, which is exactly what the form tags read.
The genres list reaches every view of the controller as a request attribute through @ModelAttribute.
The form page uses the tags of the table above together with spring:message:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<!DOCTYPE html>
<html>
<body>
<h1><spring:message code="book.new"/></h1>
<form:form modelAttribute="book" method="post" action="${pageContext.request.contextPath}/books">
<form:errors path="*" cssClass="errorBox"/>
<p><form:label path="title"><spring:message code="book.field.title"/></form:label>: <form:input path="title"/> <form:errors path="title"/></p>
<p><form:label path="author"><spring:message code="book.field.author"/></form:label>: <form:input path="author"/> <form:errors path="author"/></p>
<p><form:label path="year"><spring:message code="book.field.year"/></form:label>: <form:input path="year" type="number"/> <form:errors path="year"/></p>
<p><spring:message code="book.field.genre"/>: <form:select path="genre" items="${genres}"/></p>
<p><form:radiobuttons path="genre" items="${genres}"/></p>
<p><spring:message code="book.field.available"/>: <form:checkbox path="available"/></p>
<p>Tags: <form:checkboxes path="tags" items="${genres}"/></p>
<form:button name="save"><spring:message code="book.save"/></form:button>
</form:form>
</body>
</html>
Source: Spring Framework — The form tag
Submitting title=&author=x&year=2000 (an empty title) re-rendered the form with the submitted values, the summary in the
errorBox class and the message resolved from the code NotBlank.book.title next to the field:
<form id="book" action="/g7spring/books" method="post">
<span id="book.errors" class="errorBox">The title is required</span>
<p><label for="title">Title</label>: <input id="title" name="title" type="text" value=""/> <span id="title.errors">The title is required</span></p>
<p><label for="author">Author</label>: <input id="author" name="author" type="text" value="x"/> </p>
<p><label for="year">Year</label>: <input id="year" name="year" type="number" value="2000"/> </p>
<p>Genre: <select id="genre" name="genre"><option value="NOVEL" selected="selected">NOVEL</option><option value="HORROR">HORROR</option><option value="POETRY">POETRY</option></select></p>
<p><span><input id="genre1" name="genre" type="radio" value="NOVEL" checked="checked"/><label for="genre1">NOVEL</label></span>...</p>
<p>Available: <input id="available1" name="available" type="checkbox" value="true" checked="checked"/><input type="hidden" name="_available" value="on"/></p>
<button id="save" name="save" type="submit" value="Submit">Save</button>
</form>
Observations that matter when the output is compared with the reference (the reference shows older output):
-
the
formelement gets anid(the model attribute name,commandby default), so twoform:formelements on one page without amodelAttributeoridboth printid="command", which is invalid HTML (thebooks.jsplist printed that for every delete form); -
the error span is
<span id="title.errors">; the reference’s sample printsname="firstName.errors"; -
the error messages come from the message source under the usual codes (
NotBlank.book.title, thenNotBlank.title, thenNotBlank.java.lang.String, thenNotBlank; the page used the first and most specific), so the form is localised by the samemessages_*.propertiesfiles as the rest of the page.
The checkbox and its marker field
HTML does not send an unchecked checkbox, so Spring follows its own convention: for each checkbox the tag also renders a
hidden field whose name is the checkbox’s name prefixed by an underscore. The reference says this tells Spring that "the
checkbox was visible in the form, and I want my object to which the form data binds to reflect the state of the checkbox, no
matter what". Without the marker, un-ticking available would not change the bean.
<input id="available1" name="available" type="checkbox" value="true" checked="checked"/><input type="hidden" name="_available" value="on"/>
The marker value is on in Spring 7.0.9; the reference’s listing shows 1. A client that posts the form by hand (a test
script, curl) must include _available=on for the unchecked case, otherwise the field keeps whatever the bean already had:
the first POST of this page’s run, without the marker, left available at its default true. The single checkbox works
in the three approaches the reference lists (a Boolean property, a collection or array property, any other type compared
with value) and always generates the same HTML. form:checkboxes and form:radiobuttons take items as an array, a list
or a map (the key is the value and the map value the label); for objects, itemValue and itemLabel name the bean properties.
select, option and options
form:select path="genre" items="${genres}" renders the select with the bound value marked selected="selected";
nesting form:option value="-" label="--Please Select" and form:options items="${countries}" itemValue="code"
itemLabel="name" gives a display-only default entry above the data-driven options and produces the same HTML. A map in
items uses the keys as values and the map values as labels, and itemValue/itemLabel then apply to the key and the value
of the entry. A property bound as an array or collection renders multiple="true" on the select automatically.
form:errors
form:errors renders field errors in a span (the element attribute changes the tag, delimiter separates several
messages with <br/> by default). path="*" shows all errors, a field name shows that field’s, and an omitted path shows only
the object-level errors:
<form:errors path="*" cssClass="errorBox"/>
<form:errors path="title"/>
Submitting an empty title, an empty author and year=10 gave The author is required<br/>The year must be 1450 or later<br/>The
title is required in the summary (the order is the one of the binding result, not of the form) and the matching message next
to each field. The same constraints,
validated with the Bean Validation annotations of the Hibernate
Validator page, show up here without any Validator class; a hand-written Validator (the reference’s UserValidator with
ValidationUtils.rejectIfEmptyOrWhitespace) reaches the same form:errors tags through the same BindingResult.
Other tags: password, hidden, textarea
form:password renders type="password" and, by default, does not print the bound value; showPassword="true" prints it, which is
almost never wanted and which the pitfalls table lists. form:hidden renders a bound hidden field (an unbound
hidden value is a plain HTML input). form:textarea takes rows and cols. All three escape the bound value.
HTTP Method Conversion
Browsers submit only GET and POST. For a REST-style URL such as DELETE /books/1 the reference describes the
workaround: use JavaScript, or post with the real method as an additional parameter in a hidden field. Spring’s
HiddenHttpMethodFilter reads that parameter (named _method by default; the methodParam property of the filter changes it) and
wraps the request so that getMethod() returns the real method. The Javadoc is precise about the limits: only PUT,
DELETE and PATCH are accepted, and for a multipart POST the filter must run after the multipart processing because it
reads a body parameter. The filter is a plain servlet filter, so it works in front of any framework, not just Spring MVC.
The form tag cooperates: <form:form method="delete"> renders a POST form with the hidden field, and the page books.jsp
used it for the delete button of every book:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<!DOCTYPE html>
<html>
<head>
<title><spring:message code="books.title"/></title>
</head>
<body>
<h1><spring:message code="books.title"/></h1>
<p><spring:message code="book.count" arguments="${books.size()}"/></p>
<ul>
<c:forEach var="book" items="${books}">
<li>
<spring:url var="detail" value="/books/{id}"><spring:param name="id" value="${book.id}"/></spring:url>
<a href="${detail}"><c:out value="${book.title}"/></a> (<c:out value="${book.author}"/>)
<form:form method="delete" action="${detail}"><button type="submit"><spring:message code="book.delete"/></button></form:form>
</li>
</c:forEach>
</ul>
<p><a href="<spring:url value='/books/new'/>"><spring:message code="book.new"/></a></p>
</body>
</html>
The output for the first book (cookie present):
<a href="/g7spring/books/1">Don Quixote</a> (Miguel de Cervantes)
<form id="command" action="/g7spring/books/1" method="post"><input type="hidden" name="_method" value="delete"/><button type="submit">Delete</button></form>
The filter was registered by getServletFilters() of the initializer above (the reference shows the web.xml form,
<filter-class>org.springframework.web.filter.HiddenHttpMethodFilter</filter-class> mapped to the dispatcher servlet). The test:
curl -s -b cj -o /dev/null -w "%{http_code} %{redirect_url}\n" -X POST http://localhost:18080/g7spring/books/1 -d '_method=delete'
# 302 http://localhost:18080/g7spring/books
curl -s -b cj -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:18080/g7spring/books/2
# 302
Source: HiddenHttpMethodFilter Javadoc
The first request is a real POST converted by the filter, the second a real DELETE that needs no conversion. Two
related points. The form:form attribute methodParam renames the hidden field (a method="put" methodParam="verb"
form rendered <input type="hidden" name="verb" value="put"/>), and the filter’s methodParam must then be set to the same
name. And a GET with _method=delete is not converted: GET /books/1?_method=delete returned 405 and the book was still
in the list, so a link can never delete anything.
Spring Boot and JSP
Spring Boot supports JSP with narrower promises than Spring Framework, and the Boot reference says so twice. In the section on template engines: Spring MVC "supports a variety of templating technologies, including Thymeleaf, FreeMarker, and JSPs", Boot auto-configures FreeMarker, Groovy, Thymeleaf and Mustache, and "If possible, JSPs should be avoided. There are several known limitations when using them with embedded servlet containers." In the section on embedded servlet containers, under JSP Limitations, the reference lists the facts:
-
with Jetty and Tomcat, JSP "should work if you use war packaging"; an executable
warworks withjava -jarand is also deployable to any standard container; -
"JSPs are not supported when using an executable jar";
-
a custom
error.jspdoes not override the default view for error handling, and the reference’s advice is to use custom error pages instead; -
when the application is started by
mvn spring-boot:runorgradle bootRunwith a source directory other than the standardsrc/main/webapp, the environment variableWAR_SOURCE_DIRECTORYtells Boot where the JSPs are.
Source: Spring Boot — JSP Limitations
The consequence is that a Boot application with JSP is a WAR project. Three steps from Boot’s traditional-deployment guide
make a WAR that deploys to an external Tomcat and also runs with java -jar: a SpringBootServletInitializer subclass, war
packaging, and the embedded container marked provided.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<packaging>war</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
tomcat-embed-jasper is what compiles JSPs when the application runs with java -jar (the same WAR started with
java -jar g7springboot.war --server.port=18199 served /books with status 200); in an external Tomcat the container’s own
Jasper is used and the provided scope keeps the embedded one out of WEB-INF/lib (it is packaged under WEB-INF/lib-provided
for java -jar).
With the Boot starters, Boot’s spring-boot-starter-webmvc replaces the plain spring-webmvc dependency of the first
variant, spring-boot-starter-validation brings Hibernate Validator and the provided scope is applied to the embedded
Tomcat. The JSTL API artifact declares the Servlet API and the EL API as compile dependencies: the build tree of this WAR
contained jakarta.servlet-api 6.1.0 and jakarta.el-api 5.0.0 in WEB-INF/lib, which Tomcat deploys without a complaint
but which a tidy build excludes or marks provided.
package org.example.bookshelf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class BookshelfApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(BookshelfApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(BookshelfApplication.class, args);
}
}
Boot has no registry.jsp() to write: its auto-configuration provides the resolver, and the two properties
spring.mvc.view.prefix and spring.mvc.view.suffix choose the folder and the suffix. With the same BookController and
JSPs copied from the first variant, the properties below produced the same pages (GET /g7springboot/books returned 200,
Accept-Language: es gave Nuevo libro, so Boot’s message-source auto-configuration picked up messages.properties
without a MessageSource bean):
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
spring.mvc.hiddenmethod.filter.enabled=true
Boot does not register HiddenHttpMethodFilter by default: without the third property the same _method=delete request
returned 405, with it 302. The property is spring.mvc.hiddenmethod.filter.enabled. A property such as
server.servlet.session.tracking-modes belongs to the embedded server, so it has no effect in a WAR deployed on Tomcat
(the jsessionid of the spring:url section was still present); the container’s web.xml or context.xml
configure those settings in that case. The same holds for the Boot error page: the reference’s section Error Handling in a
WAR Deployment explains that when deployed to a servlet container, Boot uses an error page filter to forward to the error
page, which can only forward if the response has not been committed, and that some containers need additional configuration.
The recommendation of the Spring and Boot documentation, and of this section, is to use JSP when the application already
has JSPs or must run in a standard servlet container, and a template engine that is a first-class Boot citizen for a new
Boot application: the Thymeleaf with Spring MVC page renders the same
controllers, and the web UI frameworks comparison puts the choices side by
side. The JSP and Thymeleaf form tags map one to one: form:form and form:input correspond to th:object and th:field
(see the Thymeleaf forms and data binding page); only the controller side stays
the same.
Source: Spring Boot — Template Engines
Worked Example: the Bookshelf Catalogue End to End
The sequence below uses the first variant (plain Spring MVC, context /g7spring) on Tomcat 11.0.26 and shows the whole round
trip with curl: list, empty form, an invalid submission, a valid one, and a delete through the converted method. A cookie
jar keeps the session, so no jsessionid appears in the URLs.
B=http://localhost:18080/g7spring
curl -s -c cj $B/books/new > /dev/null # starts the session
curl -s -b cj $B/books | grep -E 'Showing|<a href'
curl -s -b cj -X POST $B/books -d 'title=&author=&year=10&genre=HORROR' # invalid: 200 + the form
curl -s -b cj -o /dev/null -w "%{http_code} %{redirect_url}\n" -X POST $B/books \
-d 'title=Frankenstein&author=Mary Shelley&year=1818&genre=HORROR&_available=on'
curl -s -b cj -o /dev/null -w "%{http_code} %{redirect_url}\n" -X POST $B/books/1 -d '_method=delete'
curl -s -b cj $B/books | grep -E 'Showing|<a href'
Source: Spring Framework — JSP and JSTL
The results, in order: the first list printed Showing 2 books with Don Quixote and Dracula; the invalid submission returned
status 200 and the form with The title is required, The author is required and The year must be 1450 or later (the
values year=10 and genre=HORROR kept, and HORROR selected); the valid one returned 302 http://localhost:18080/g7spring/books
(the Post/Redirect/Get pattern, which the flow diagram does not show because the redirect ends the request before any
view is resolved); the delete returned 302 as well; and the last list printed Showing 2 books with Dracula and Frankenstein.
The same transcript against the Boot WAR (context /g7springboot, with the hiddenmethod property on) gave the same
statuses.
Pitfalls and Error Names
The messages are the ones Tomcat 11.0.26 and Spring 7.0.9 printed for the Bookshelf.
| Symptom or message | Cause | Fix |
|---|---|---|
|
|
Use |
|
The classes were compiled without |
Set |
|
A |
Add the empty object in the |
|
|
|
|
A |
Never build the expression from request data; guard the page, or remove the tag |
A request parameter runs as SpEL |
|
Fixed, literal expressions only; see the Security page of this section |
A message or |
The generic Spring tags do not escape by default, unlike the form tags |
|
|
|
|
Two forms with |
|
Give each |
An unchecked checkbox keeps its old value |
The marker field |
Use |
A password reappears in the page |
|
Leave |
|
Only |
Add a |
404 for a JSP in an executable jar |
Boot’s JSP limitation: JSPs need |
Package a |
|
|
Add the key to |
Boot’s |
Boot’s JSP limitation: custom |
Use Boot’s error pages ( |
Source: Spring Boot — JSP Limitations
What Changed Recently
-
Spring Framework 7.0 and Boot 4.0/4.1: the baseline is Jakarta EE 11 with Servlet 6.1, so Tomcat 11.0 or Jetty 12.1 are the supported containers for a WAR; the release notes say that "applications should be deployed on recent Servlet containers like Tomcat 11+ and Jetty 12.1+". Undertow support was removed because it lacks Servlet 6.1. A Spring 7 WAR is therefore not a target for Tomcat 10.1.
-
The XML namespace
mvc:is deprecated in Spring 7.0 in favour of the Java configuration;registry.jsp()is the current form. -
The
spring-form.tldof 7.0.9 has nocommandNameattribute onform:form; the model attribute ismodelAttribute, which the reference chapter uses throughout. Pages that still usecommandNamemust be updated. -
Themes: the 7.0.9
spring.tldhas nospring:themetag (CVE-2011-2730 above named it in 2011), so a page that used it needs another mechanism, such as a CSS file chosen by the page. -
spring-jclremoved: logging uses Apache Commons Logging 1.3.0 directly; transparent for a JSP application. -
JSTL: the API moved to
jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api3.0.2 and the taglib URIs tojakarta.tags.*. Pages that still declarehttp://java.sun.com/jsp/jstl/core(valid in the Spring reference’s own old samples) must change them. -
Boot: the Boot 4.1.1 build of this page uses
spring-boot-starter-webmvcandspring-boot-starter-tomcat(withprovidedscope for the WAR); check the starter names when moving a project from Boot 3. -
Jakarta Validation:
jakarta.validation.constraints.*, Hibernate Validator 9.1.4.Final; thejavax.validationimports of old samples no longer compile.
The javax.* to jakarta.* namespace move itself (Servlet 5, Spring 6) is described on the
Spring Boot getting started page, and the whole picture of what changed in
Jakarta EE and in Struts is on the What’s Changed & Migration page of this section.
Where the Books Are Outdated
None of the four books in the section bibliography teaches Spring MVC with JSP: they were written for the Servlet and JSP containers of 2001 to 2008 and for Struts as the application framework. What they say that conflicts with a Spring MVC application today:
-
Web Development with JavaServer Pages (2001) shows the page-centric and servlet-centric architectures with a hand-written front controller and scriptlets or beans in the page. Spring’s
DispatcherServletis that front controller, the model is aModeland request attributes, and the page contains no Java; the book’s helper classes,jsp:useBeanandjsp:getPropertypages are replaced by EL and by the binding tags. -
JSTL in Action (2002) uses the JSTL 1.0 URIs (
http://java.sun.com/jstl/core), the_rtlibraries and thefmttags with a bundle chosen byfmt:setBundleor a context parameter. With Spring the bundle is theMessageSource, exposed byJstlView; the URIs arejakarta.tags.*and no_rtlibrary exists. -
Struts in Action (2002) and Struts 2 in Action (2008) use their own
html:ors:tags to bind a form to anActionFormor an action. Theform:library is Spring’s equivalent, bound to a@ModelAttributebean with Bean Validation; chapter 9 of the second book, on Spring and JPA, wires Struts actions with Spring 2 XML,ContextLoaderListenerandOpenEntityManagerInViewFilter, none of which is the way to render a JSP with Spring MVC today. The Struts-side integration (the Spring plugin) is written on the Spring Integration page of the Struts part. -
All four books' deployment descriptors are
web.xmldocuments; a Spring MVC WAR in 2026 has none (theWebApplicationInitializer) or generates the equivalent (SpringBootServletInitializer).
When to Choose What
| Situation | Choice |
|---|---|
Existing JSP application, adding Spring for controllers, validation and messages |
Spring MVC with |
New application that needs a JSP-like view and deploys to a standard servlet container |
Still possible, but a template engine (Thymeleaf) avoids the JSP limitations and tests more easily |
New Spring Boot application, executable jar |
Not JSP: use Thymeleaf, FreeMarker or Mustache; JSPs are not supported in an executable jar |
Spring Boot application that must keep its JSPs |
|
Pure REST backend |
No view technology; see the REST APIs page |
Securing the controllers is Spring Security’s job (see the Spring Security page); testing them uses MockMvc (see the unit and integration testing page), and how to test and debug the JSPs themselves is on the Testing and Debugging JSP Applications page of this section.
References
-
Spring Framework — JSP and JSTL (all sections: view resolvers, JSPs versus JSTL, Spring’s JSP tag library, the form tag library, HTTP method conversion, HTML5 tags)
-
Spring Framework — Context Hierarchy and Servlet Container Config