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

InternalResourceViewResolver

Registered by registry.jsp(), by a bean, or by Boot’s spring.mvc.view.* properties

View class

InternalResourceView, or JstlView when the JSTL API is on the classpath

JstlView is what lets fmt: tags use Spring’s locale and message source

Spring tag libraries

http://www.springframework.org/tags (spring.tld, 11 tags) and http://www.springframework.org/tags/form (spring-form.tld, 15 tags)

Both are inside spring-webmvc-7.0.9.jar; the URIs are taglib identifiers, not downloads

Jakarta Tags

3.0 (jakarta.tags.core, jakarta.tags.fmt)

The jakarta.tags.* URIs replace http://java.sun.com/jsp/jstl/*

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.

pom.xml (the dependency part of the Spring Framework variant)
<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>

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.

Book.java (a bean with Bean Validation constraints; getters and setters omitted)
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

sequenceDiagram participant B as Browser participant T as Tomcat connector and filters participant D as DispatcherServlet participant C as BookController participant R as InternalResourceViewResolver participant V as JstlView participant J as Jasper JspServlet B->>T: GET /g7spring/books T->>D: doFilter chain then service D->>C: handler method with Model C-->>D: view name books and model attributes D->>R: resolveViewName books, locale R-->>D: JstlView for /WEB-INF/views/books.jsp D->>V: render model, request, response V->>V: copy model to request attributes, expose locale and MessageSource to JSTL V->>J: RequestDispatcher.forward to /WEB-INF/views/books.jsp J-->>B: HTML written by the compiled page
Figure 1. A Spring MVC request that ends in a JSP: the controller returns a name, the resolver builds the view, the view forwards

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

DispatcherServlet

The front controller, mapped to / by the initializer below; it is a servlet, so every filter of the filter chain runs before it

Handler (@Controller)

Returns the logical view name and fills the Model; never names a .jsp file

ViewResolver

Maps a logical name to a view; InternalResourceViewResolver prepends the prefix and appends the suffix and always succeeds, which makes it the last resolver in any chain

InternalResourceView / JstlView

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.

WebConfiguration.java
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

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.

The same resolver declared as a bean
@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

prefix, suffix

Added around the logical name to form the resource path

Empty strings for the bean; /WEB-INF/ and .jsp for registry.jsp()

viewClass

The view class for every resolved name

InternalResourceView, or JstlView if the JSTL API is present

requestContextAttribute

Request attribute that exposes the Spring RequestContext to the page

not set

viewNames

Patterns of the names this resolver handles (for several resolvers in one chain)

every name

order

Position in the resolver chain

lowest priority; keep it last

alwaysInclude, exposeContextBeansAsAttributes

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.

BookshelfInitializer.java
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.

The messages file used for these runs, which both libraries and the validation messages of the form share:

messages.properties (the Spanish file overrides only three keys)
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)

spring:message

Resolves a message code against the MessageSource and the current locale; a literal text is the fallback

code, message (a MessageSourceResolvable), arguments, argumentSeparator, text, var, scope, htmlEscape, javaScriptEscape

spring:argument

Supplies one argument in the body of spring:message (a nested tag instead of the arguments list)

value

spring:url

Builds a URL: adds the context path, fills {name} templates from nested spring:param tags, encodes the result

value*, context, var, scope, htmlEscape, javaScriptEscape

spring:param

A request or template parameter for the enclosing spring:url

name*, value

spring:eval

Evaluates a Spring Expression Language (SpEL) expression and prints or stores the result

expression*, var, scope, htmlEscape, javaScriptEscape

spring:escapeBody

Escapes its body for HTML and/or JavaScript

htmlEscape, javaScriptEscape

spring:htmlEscape

Sets the default HTML escaping for the rest of the page

defaultHtmlEscape*

spring:bind

Exposes a status variable (expression, value, errors) for a property or object of the bound model

path*, ignoreNestedPath, htmlEscape

spring:nestedPath

Prefixes every nested spring:bind path

path*

spring:hasBindErrors

Evaluates its body only if the named object has binding errors, and exposes errors

name*, htmlEscape

spring:transform

Applies the property editor of a bound property to another value

value*, var, scope, htmlEscape

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&lt;i&gt;x&lt;/i&gt;
message text   : <b>bold</b>
message escaped: &lt;b&gt;bold&lt;/b&gt;
after page default on, eval : Don Quixote&lt;i&gt;x&lt;/i&gt;
after page default on, msg  : &lt;b&gt;bold&lt;/b&gt;
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)

form:form

An HTML form element; exposes the binding path to the nested tags

modelAttribute, action, servletRelativeAction, method, methodParam, enctype, acceptCharset, autocomplete, htmlEscape

form:input

input of type="text" by default; accepts other types such as email, number or date

path*, cssErrorClass, size, maxlength, readonly, autocomplete

form:password

input type="password"; the value is not shown unless asked

path*, showPassword

form:hidden

input type="hidden" with the bound value

path*

form:textarea

textarea

path*, rows, cols, readonly

form:checkbox

One checkbox plus a marker field

path*, value, label

form:checkboxes

A checkbox per item of a list, array or map

path*, items*, itemValue, itemLabel, delimiter, element

form:radiobutton

One radio button

path*, value, label

form:radiobuttons

A radio button per item

path*, items, itemValue, itemLabel, delimiter, element

form:select

A select; items renders the options itself, or nest form:option and form:options

path*, items, itemValue, itemLabel, multiple, size

form:option

One option, selected when it equals the bound value

value*, label

form:options

An option per item

items, itemValue, itemLabel

form:errors

Field or object errors in a span (or the element named by element)

path, delimiter, cssClass, element

form:label

A label whose for is the id of the bound field

path*, for

form:button

A button element (not bound)

name, value, disabled

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:

BookController.java
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";
    }
}

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:

book-form.jsp
<%@ 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>

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 form element gets an id (the model attribute name, command by default), so two form:form elements on one page without a modelAttribute or id both print id="command", which is invalid HTML (the books.jsp list printed that for every delete form);

  • the error span is <span id="title.errors">; the reference’s sample prints name="firstName.errors";

  • the error messages come from the message source under the usual codes (NotBlank.book.title, then NotBlank.title, then NotBlank.java.lang.String, then NotBlank; the page used the first and most specific), so the form is localised by the same messages_*.properties files 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:

books.jsp
<%@ 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

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 war works with java -jar and is also deployable to any standard container;

  • "JSPs are not supported when using an executable jar";

  • a custom error.jsp does 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:run or gradle bootRun with a source directory other than the standard src/main/webapp, the environment variable WAR_SOURCE_DIRECTORY tells Boot where the JSPs are.

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.

pom.xml (Boot variant, the parts that differ)
<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.

BookshelfApplication.java
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):

application.properties
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.

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'

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

HTTP 404, JSP file [/WEB-INF/books.jsp] not found

registry.jsp() uses the prefix /WEB-INF/, but the page is in /WEB-INF/views/

Use registry.jsp("/WEB-INF/views/", ".jsp") or set spring.mvc.view.prefix

IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.

The classes were compiled without -parameters, so @PathVariable long id has no name

Set maven.compiler.parameters to true (the Boot parent does), or name the variable: @PathVariable("id")

JasperException with root cause IllegalStateException: Neither BindingResult nor plain target object for bean name 'book' available as request attribute

A form:form modelAttribute="book" page was rendered without a model attribute of that name (a controller that returns the view for a GET forgot model.addAttribute)

Add the empty object in the GET handler; keep the same name in @ModelAttribute, the handler and modelAttribute

HTTP 405 for a POST with _method=delete

HiddenHttpMethodFilter is not registered: in Boot it is off by default

spring.mvc.hiddenmethod.filter.enabled=true, or getServletFilters() in the initializer

JasperException caused by 'expressionString' must not be null or blank

A spring:eval whose expression evaluated to an empty string (a missing request parameter)

Never build the expression from request data; guard the page, or remove the tag

A request parameter runs as SpEL

<spring:eval expression="${param.expr}"/> evaluates user text as an expression (expr=T(java.lang.Math).abs(-5) printed 5)

Fixed, literal expressions only; see the Security page of this section

A message or spring:eval result inserts markup

The generic Spring tags do not escape by default, unlike the form tags

htmlEscape="true", spring:htmlEscape, or the defaultHtmlEscape context parameter; print data with c:out

;jsessionid=... in the URLs of the first page

spring:url and c:url encode URLs, and Tomcat rewrites them when the client has no cookie yet

tracking-mode COOKIE in web.xml; the Boot property is for the embedded server only

Two forms with id="command" on a page

form:form without modelAttribute or id defaults to the model attribute command

Give each form:form its own modelAttribute or id

An unchecked checkbox keeps its old value

The marker field _name was not posted (a hand-made client, or the tag was replaced by a plain input)

Use form:checkbox, or post _available=on yourself

A password reappears in the page

form:password showPassword="true" prints the bound value

Leave showPassword off, which is the default

GET /books/1 returns 405

Only DELETE is mapped for that path, and a link is a GET

Add a GET handler; a destructive action is never a link

404 for a JSP in an executable jar

Boot’s JSP limitation: JSPs need war packaging

Package a war; or use a template engine

JspTagException: No message found under code 'zzz.missing' for locale 'en_US'.

spring:message with no message for the locale and no text

Add the key to messages.properties or a text default

Boot’s error.jsp is ignored

Boot’s JSP limitation: custom error.jsp does not override the default error view

Use Boot’s error pages (error/404.html or ErrorController) instead

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.tld of 7.0.9 has no commandName attribute on form:form; the model attribute is modelAttribute, which the reference chapter uses throughout. Pages that still use commandName must be updated.

  • Themes: the 7.0.9 spring.tld has no spring:theme tag (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-jcl removed: 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-api 3.0.2 and the taglib URIs to jakarta.tags.*. Pages that still declare http://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-webmvc and spring-boot-starter-tomcat (with provided scope 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; the javax.validation imports 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 DispatcherServlet is that front controller, the model is a Model and request attributes, and the page contains no Java; the book’s helper classes, jsp:useBean and jsp:getProperty pages 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 _rt libraries and the fmt tags with a bundle chosen by fmt:setBundle or a context parameter. With Spring the bundle is the MessageSource, exposed by JstlView; the URIs are jakarta.tags.* and no _rt library exists.

  • Struts in Action (2002) and Struts 2 in Action (2008) use their own html: or s: tags to bind a form to an ActionForm or an action. The form: library is Spring’s equivalent, bound to a @ModelAttribute bean with Bean Validation; chapter 9 of the second book, on Spring and JPA, wires Struts actions with Spring 2 XML, ContextLoaderListener and OpenEntityManagerInViewFilter, 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.xml documents; a Spring MVC WAR in 2026 has none (the WebApplicationInitializer) or generates the equivalent (SpringBootServletInitializer).

When to Choose What

Situation Choice

Existing JSP application, adding Spring for controllers, validation and messages

Spring MVC with registry.jsp(...) and the spring:/form: tags, as on this page; Tomcat 11 WAR

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

war packaging with SpringBootServletInitializer, tomcat-embed-jasper and the provided Tomcat starter

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.