Spring Boot

JSP Integration

How to use JavaServer Pages (JSP) with Spring Boot 3: why it needs WAR packaging and Tomcat Jasper, the dependencies and Jakarta Tags (JSTL 3) taglib URIs that trip up most setups, view resolver configuration, controllers, JSTL templates, Spring form handling with validation, layouts, deployment to external Tomcat, tests, and how to migrate to Thymeleaf. For new projects, Thymeleaf or FreeMarker are the better choice.

JSP in Spring Boot: How It Fits

JavaServer Pages (JSP) are HTML files with embedded tags and expressions. When a request arrives, the servlet container's Jasper engine compiles the JSP into a servlet and runs it. Because of that, JSP is tied to the servlet container in a way that Thymeleaf and FreeMarker are not, and that connection is the source of almost every Spring Boot restriction described on this page. From the controller's point of view nothing changes. A handler method still adds attributes to the Model and returns a view name. Spring's InternalResourceViewResolver adds a prefix and a suffix to that name, so the view name users/list becomes /WEB-INF/views/users/list.jsp, and the request is forwarded to that file. The differences are in packaging, dependencies and configuration. If you are starting a new project, prefer Thymeleaf. Choose JSP when you are migrating an existing application, when your team already has a large JSP codebase, or when the application is deployed as a WAR to an external Tomcat.

JSP Limitations in Spring Boot

Spring Boot's JSP support is limited by how embedded servlet containers work. The list below follows the limitations described in the Spring Boot reference documentation, and it is worth reading before you commit to JSP, because these limits affect how the application is packaged and deployed. The most important one is packaging. A JSP cannot be served from an executable JAR, so a JSP-based project must be packaged as a WAR. An executable WAR still runs with java -jar, and the same WAR can be deployed to an external Tomcat. Among embedded servers, Tomcat is the one to use, because Undertow has no JSP support. Finally, a custom error.jsp does not replace Spring Boot's default error view, so use a custom error page or a @ControllerAdvice to render errors.
Shell
# ── Known limitations (see the Spring Boot reference documentation): ───

# 1. No executable JAR support
#    A JSP cannot be served from an executable JAR because of a hard-coded
#    file pattern in Tomcat. Package the application as a WAR instead:
#      - an executable WAR still runs with:  java -jar myapp.war
#      - the same WAR can be deployed to an external Tomcat
#    During development, mvn spring-boot:run also works.

# 2. Undertow does not support JSP
#    Use embedded Tomcat (the default in spring-boot-starter-web).

# 3. A custom error.jsp does not replace the default error view
#    Use Spring Boot custom error pages or a @ControllerAdvice instead.

# 4. Edited JSPs may not be picked up automatically
#    While developing, set server.servlet.jsp.init-parameters.development=true
#    (shown in the configuration section), or restart after changing a JSP.

# ── When JSP is acceptable: ───────────────────────────────────────────
# - Migrating a legacy Spring MVC application to Spring Boot
# - Team has deep JSP expertise and no budget to migrate
# - Deploying as WAR to an external Tomcat (standard enterprise setup)

# ── Recommended alternatives: ────────────────────────────────────────
# - Thymeleaf   → spring-boot-starter-thymeleaf  (recommended)
# - FreeMarker  → spring-boot-starter-freemarker
# - Mustache    → spring-boot-starter-mustache

Dependencies

To use JSP you need three things: the Jasper engine (tomcat-embed-jasper), a JSTL implementation, and WAR packaging. The Jasper dependency uses provided scope, which is the usual choice for a WAR: Spring Boot still puts provided libraries on the classpath when you run it with java -jar (they go into WEB-INF/lib-provided), and an external Tomcat supplies its own copy. One consequence: when you start the application from an IDE, enable the option to include provided-scope dependencies in the run classpath, otherwise Jasper is missing and JSPs are not rendered. If you only ever deploy to an external Tomcat, also mark spring-boot-starter-tomcat as provided, as shown. Match the JSTL version to your Spring Boot generation. Spring Boot 3 uses Jakarta EE 10 (Tomcat 10.1), which pairs with Jakarta Standard Tag Library 3.0. That version changed the taglib URIs: use jakarta.tags.core, jakarta.tags.fmt and jakarta.tags.functions, not the old http://java.sun.com/jsp/jstl/... addresses that most online examples still show. Older Spring Boot 2 projects (javax namespace, Tomcat 9) use JSTL 1.2 and the old URIs. If your Boot version does not manage the JSTL versions, add a 3.0.x version to both JSTL dependencies. Also remember that Spring Boot 3 needs Tomcat 10.1 or later if you deploy to an external container.
XML
<!-- pom.xml — packaging must be war: -->
<packaging>war</packaging>

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

    <!-- Only if you deploy to an external Tomcat (the WAR still runs with java -jar): -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-tomcat</artifactId>
        <scope>provided</scope>
    </dependency>

    <!-- Jasper — JSP compiler: -->
    <dependency>
        <groupId>org.apache.tomcat.embed</groupId>
        <artifactId>tomcat-embed-jasper</artifactId>
        <scope>provided</scope>
    </dependency>

    <!-- JSTL (Jakarta Tags 3.0 for Spring Boot 3) — taglib URIs are jakarta.tags.*: -->
    <dependency>
        <groupId>jakarta.servlet.jsp.jstl</groupId>
        <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
    </dependency>
    <dependency>
        <groupId>org.glassfish.web</groupId>
        <artifactId>jakarta.servlet.jsp.jstl</artifactId>
    </dependency>
</dependencies>

<!-- Gradle: apply the 'war' plugin and keep JSPs in src/main/webapp -->

Configuration and File Layout

Set the view prefix and suffix in application.yml so Spring can turn view names into JSP paths. Keep JSP files under src/main/webapp/WEB-INF, not under src/main/resources/templates, which is the Thymeleaf location. Files under WEB-INF cannot be requested directly by a browser, so they are only reachable through your controllers. Static files such as CSS and images go in src/main/resources/static and are served from the root path.
yaml
# ── application.yml: ──────────────────────────────────────────────────
spring:
  mvc:
    view:
      prefix: /WEB-INF/views/
      suffix: .jsp

# Development only — recompile edited JSPs without restarting:
server:
  servlet:
    jsp:
      init-parameters:
        development: true

# ── File layout: ──────────────────────────────────────────────────────
# src/main/
# ├── java/
# │   └── com/example/
# │       ├── MyApplication.java
# │       └── ServletInitializer.java   ← needed for external Tomcat only
# ├── resources/
# │   └── static/
# │       └── css/main.css              ← served at /css/main.css
# └── webapp/
#     └── WEB-INF/
#         ├── tags/
#         │   └── layout.tag            ← reusable page layout (tag file)
#         └── views/
#             ├── common/
#             │   └── header.jspf       ← included fragment
#             └── users/
#                 ├── list.jsp
#                 └── form.jsp

ServletInitializer and Application Class

A WAR deployed to an external Tomcat has no main() method to start it, so Spring Boot needs a class that hooks into the servlet container's startup. That class is a subclass of SpringBootServletInitializer that points to your main application class. Spring Initializr generates it when you choose War packaging. You do not need it to run the WAR with java -jar, only for deployment to an external container. The explicit view resolver bean at the bottom is optional. When spring.mvc.view.prefix and spring.mvc.view.suffix are set, Spring Boot configures an InternalResourceViewResolver itself, and it already uses JstlView when JSTL is on the classpath. Declare your own only when you need control over details such as the view class or the resolver order. If you do, do not name the bean viewResolver: Spring Boot's content-negotiating resolver is registered under that name and backs off when it finds a bean with the same name, which silently disables content negotiation for views.
Java
// ── Main application class (unchanged): ──────────────────────────────
@SpringBootApplication
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

// ── ServletInitializer — needed when deploying to an external Tomcat: ─
public class ServletInitializer extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(
            SpringApplicationBuilder application) {
        return application.sources(MyApplication.class);
    }
}

// ── Optional explicit resolver (normally auto-configured from application.yml).
//    Note the bean name: "viewResolver" would disable Boot's own resolver. ──
@Configuration
public class MvcConfig {

    @Bean
    public ViewResolver jspViewResolver() {
        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        resolver.setViewClass(JstlView.class);   // already the default when JSTL is present
        resolver.setPrefix("/WEB-INF/views/");
        resolver.setSuffix(".jsp");
        return resolver;
    }
}

Running and Deploying the WAR

Build the WAR and run it as a self-contained application with java -jar, or deploy it to an external Tomcat. When it runs under an external Tomcat, the context path comes from the WAR file name, and server.servlet.context-path in application.yml is ignored. Name the file ROOT.war to serve the application from the root path. Because deployed applications often live under a context path, never hard-code absolute URLs such as /users in JSPs: use c:url or the form tag's servletRelativeAction, as the examples below do.
Shell
# Build the WAR
mvn clean package

# Run it standalone (embedded Tomcat; JSPs work because it is a WAR)
java -jar target/myapp.war

# Or deploy to an external Tomcat. Spring Boot 3 needs Tomcat 10.1 or later (Jakarta EE 10).
cp target/myapp.war $CATALINA_HOME/webapps/
# Served at http://localhost:8080/myapp/   (context path = WAR file name)
# Name the file ROOT.war to serve from "/"

A Controller That Renders a JSP

The controller for a JSP page looks just like a controller for any other view technology. It must be a @Controller, not a @RestController: a @RestController writes the returned string to the response body, so the browser would show the text users/list instead of rendering the page. The controller adds the data to the Model and returns a view name without a prefix or a suffix. With the configuration above, the view name users/list is resolved to /WEB-INF/views/users/list.jsp. The attributes pageTitle and users are then available in that JSP as EL variables.
Java
@Controller
@RequestMapping("/users")
public class UserPageController {

    private final UserService userService;

    public UserPageController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public String list(Model model) {
        model.addAttribute("pageTitle", "All Users");
        model.addAttribute("users", userService.findAll());
        return "users/list";   // -> /WEB-INF/views/users/list.jsp
    }
}

JSP Templates with JSTL

JSP templates use the JSP Expression Language (EL) to read model attributes, written as ${name}, and JSTL for logic. The core library (prefix c) provides c:if, c:choose and c:forEach for conditions and loops, and c:url for URLs that include the application's context path. The fmt library formats dates and numbers, and the fn library provides functions such as fn:length. The taglib URIs below are the Jakarta Tags 3.0 ones used with Spring Boot 3. On Spring Boot 2 with JSTL 1.2, use http://java.sun.com/jsp/jstl/core, .../fmt and .../functions instead. Escaping deserves attention. A plain ${user.name} in a JSP prints the value without HTML escaping, so a name that contains markup would be inserted into the page as-is, which is a cross-site scripting risk. Wrap user-supplied values in c:out, which escapes them by default, as the example does. Thymeleaf's th:text escapes automatically; in JSP you have to remember to do it. The fmt:formatDate tag is built around java.util.Date. If your model uses java.time types such as LocalDate or Instant, convert them in the controller or format them to a String before adding them to the model, otherwise the tag fails. Static files such as CSS and images belong in src/main/resources/static and are served from the root path, so the stylesheet below is /css/main.css, not /static/css/main.css.
jsp
<%-- src/main/webapp/WEB-INF/views/users/list.jsp --%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c"   uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<%@ taglib prefix="fn"  uri="jakarta.tags.functions" %>
<!DOCTYPE html>
<html>
<head>
    <title>Users</title>
    <%-- Files in src/main/resources/static are served from the root path: --%>
    <link rel="stylesheet" href="<c:url value='/css/main.css'/>">
</head>
<body>

<%-- One-time message set with a redirect flash attribute: --%>
<c:if test="${not empty message}">
    <p class="notice"><c:out value="${message}" /></p>
</c:if>

<%-- c:out — prints a value and HTML-escapes it (protects against XSS): --%>
<h1><c:out value="${pageTitle}" /></h1>

<%-- c:if — conditional rendering: --%>
<c:if test="${empty users}">
    <p>No users found.</p>
</c:if>

<%-- c:forEach — iterate over a collection: --%>
<c:if test="${not empty users}">
<table>
    <tr>
        <th>Name</th><th>Email</th><th>Role</th><th>Created</th><th>Actions</th>
    </tr>
    <c:forEach items="${users}" var="user" varStatus="status">
    <tr class="${status.index % 2 == 0 ? 'even' : 'odd'}">
        <td><c:out value="${user.name}" /></td>
        <td><c:out value="${user.email}" /></td>
        <td>
            <%-- c:choose / c:when / c:otherwise — multi-branch logic: --%>
            <c:choose>
                <c:when test="${user.role == 'ADMIN'}"><span class="badge-red">Admin</span></c:when>
                <c:when test="${user.role == 'USER'}"><span class="badge-blue">User</span></c:when>
                <c:otherwise><span class="badge-gray">Unknown</span></c:otherwise>
            </c:choose>
        </td>
        <td>
            <%-- fmt:formatDate — formats java.util.Date only: --%>
            <fmt:formatDate value="${user.createdAt}" pattern="dd MMM yyyy" />
        </td>
        <td>
            <a href="<c:url value='/users/${user.id}'/>">View</a>
            <a href="<c:url value='/users/${user.id}/edit'/>">Edit</a>
        </td>
    </tr>
    </c:forEach>
</table>
</c:if>

<%-- c:url — build URLs (handles the context path): --%>
<a href="<c:url value='/users/new'/>">Add User</a>
<img src="<c:url value='/images/logo.png'/>" alt="Logo" />

<%-- fn: — JSTL functions: --%>
<p>Total: ${fn:length(users)} users</p>

</body>
</html>

Layouts and Reuse

JSP has no layout inheritance like Thymeleaf's layout dialect, but two built-in mechanisms cover most needs. The include directive pastes a fragment (conventionally with a .jspf extension) into a page at translation time, which suits headers and footers. A tag file, placed under WEB-INF/tags, defines a reusable custom tag. A layout tag wraps a page's content with the common HTML skeleton, and jsp:doBody renders whatever the page puts between the tag's start and end. Put the shared skeleton in one tag file, and each page only supplies its title and body.
jsp
<%-- src/main/webapp/WEB-INF/views/common/header.jspf --%>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<header>
    <a href="<c:url value='/'/>">Home</a> | <a href="<c:url value='/users'/>">Users</a>
</header>

<%-- src/main/webapp/WEB-INF/tags/layout.tag — a reusable page layout --%>
<%@ tag description="Common page layout" pageEncoding="UTF-8" %>
<%@ attribute name="title" required="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html>
<head>
    <title><c:out value="${title}" /></title>
    <link rel="stylesheet" href="<c:url value='/css/main.css'/>">
</head>
<body>
    <%@ include file="/WEB-INF/views/common/header.jspf" %>
    <main>
        <jsp:doBody />
    </main>
</body>
</html>

<%-- A page that uses the layout (src/main/webapp/WEB-INF/views/users/about.jsp) --%>
<%@ taglib prefix="t" tagdir="/WEB-INF/tags" %>
<t:layout title="About">
    <h1>About this application</h1>
    <p>Content of the page goes here.</p>
</t:layout>

Form Controller and Validation

A form needs two handlers: a GET that displays an empty form and a POST that processes it. Several details cause the common errors. The GET handler must add the (empty) form object to the model under the name used in modelAttribute, here userForm. If it is missing, the page fails with an error saying that neither a BindingResult nor a plain target object is available as a request attribute. In the POST handler, the BindingResult parameter must come directly after the @Valid model attribute, or Spring throws an exception instead of collecting errors. Data for dropdowns, such as the roles list, must also be present when the form is shown again after a validation failure, so supply it with a @ModelAttribute method that runs for every handler. Finally, redirect after a successful POST (the post/redirect/get pattern) so a browser refresh does not submit the form twice, and use a flash attribute to carry a one-time message through the redirect. Make the form object a JavaBean with getters and setters, not a Java record. Spring's form tags read properties through JavaBean getters such as getName(), which a record's name() accessor does not provide, so the page would fail with an invalid property error. Validation annotations need spring-boot-starter-validation.
Java
// ── Form object: a JavaBean (not a record) ───────────────────────────
@Getter @Setter   // Lombok, or write plain getters and setters
public class UserForm {

    @NotBlank @Size(max = 100)
    private String name;

    @NotBlank @Email
    private String email;

    @NotNull
    private Role role;

    private boolean active = true;
}

// ── Controller ────────────────────────────────────────────────────────
@Controller
@RequestMapping("/users")
public class UserFormController {

    private final UserService userService;

    public UserFormController(UserService userService) {
        this.userService = userService;
    }

    // Runs before every handler in this controller, including after a failed POST
    @ModelAttribute("roles")
    public Role[] roles() {
        return Role.values();
    }

    @GetMapping("/new")
    public String newForm(Model model) {
        model.addAttribute("userForm", new UserForm());   // required by modelAttribute="userForm"
        return "users/form";
    }

    @PostMapping
    public String create(@Valid @ModelAttribute("userForm") UserForm form,
                         BindingResult result,            // must directly follow the form object
                         RedirectAttributes redirect) {
        if (result.hasErrors()) {
            return "users/form";                          // redisplay with the errors
        }
        userService.create(form);
        redirect.addFlashAttribute("message", "User created");
        return "redirect:/users";                         // post/redirect/get
    }
}

JSP Form Handling

Forms use Spring's form tag library, which binds each input to a property of the form object. form:input fills the field from the model, form:errors prints validation messages for it, and form:select and form:checkbox do the same for lists and booleans. The form:form tag's servletRelativeAction attribute adds the application's context path (and the servlet mapping) to the action, so the form still works when the WAR is deployed under a path such as /myapp. A hard-coded action="/users" would post to the server root and miss the application. If Spring Security is on the classpath, form:form also adds the hidden CSRF token automatically. A plain HTML form tag does not, so a POST from it would be rejected until you add the token yourself.
jsp
<%-- src/main/webapp/WEB-INF/views/users/form.jsp --%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c"    uri="jakarta.tags.core" %>

<%-- servletRelativeAction adds the context path to the action: --%>
<form:form method="post" servletRelativeAction="/users" modelAttribute="userForm">

    <div>
        <label for="name">Name</label>
        <%-- form:input — pre-populates value from the model object: --%>
        <form:input path="name" id="name" cssClass="form-control"
                    cssErrorClass="form-control is-invalid" />
        <%-- form:errors — displays validation errors for the field: --%>
        <form:errors path="name" cssClass="invalid-feedback" element="div" />
    </div>

    <div>
        <label for="email">Email</label>
        <form:input path="email" type="email" id="email" />
        <form:errors path="email" cssClass="error" element="span" />
    </div>

    <div>
        <label for="role">Role</label>
        <%-- form:select — renders <select> bound to the enum field: --%>
        <form:select path="role" id="role">
            <form:option value="">-- Select --</form:option>
            <form:options items="${roles}" />
        </form:select>
        <form:errors path="role" element="span" />
    </div>

    <div>
        <label>
            <%-- form:checkbox — binds to the boolean field: --%>
            <form:checkbox path="active" /> Active
        </label>
    </div>

    <%-- All errors (field and global) in one place: --%>
    <form:errors path="*" element="div" cssClass="alert alert-danger" />

    <button type="submit">Save</button>
    <a href="<c:url value='/users'/>">Cancel</a>

</form:form>

Testing JSP Controllers

Controller tests do not need a running server or a JSP engine. MockMvc checks the view name, the model and the forwarded URL, and it does not execute the JSP itself, so the test confirms that the controller chose the right view and supplied the right data. Rendering problems inside the JSP, such as a wrong tag library URI, show up only when you run the application. If Spring Security is on the classpath, a POST test also needs a CSRF token or security disabled for the slice.
Java
@WebMvcTest(UserPageController.class)
class UserPageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean   // use @MockBean on Spring Boot versions before 3.4
    private UserService userService;

    @Test
    void listReturnsUsersView() throws Exception {
        mockMvc.perform(get("/users"))
            .andExpect(status().isOk())
            .andExpect(view().name("users/list"))
            .andExpect(forwardedUrl("/WEB-INF/views/users/list.jsp"))
            .andExpect(model().attributeExists("users"));
    }
}

@WebMvcTest(UserFormController.class)
class UserFormControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void invalidFormIsShownAgainWithErrors() throws Exception {
        mockMvc.perform(post("/users")
                .param("name", "")           // blank
                .param("email", "not-an-email"))
            .andExpect(status().isOk())
            .andExpect(view().name("users/form"))
            .andExpect(model().attributeHasFieldErrors("userForm", "name", "email", "role"));
    }
}

Migrating Away from JSP

Moving to Thymeleaf is mostly mechanical, because controllers do not change. Move each view from src/main/webapp/WEB-INF/views to src/main/resources/templates, switch the dependency to spring-boot-starter-thymeleaf, and translate the tags. The usual mapping is: - c:out value becomes th:text, which escapes by default. - c:if becomes th:if, and c:forEach becomes th:each. - c:choose with c:when becomes th:switch with th:case. - c:url becomes the link expression @{/path}. - form:form, form:input and form:errors become th:object, th:field and th:errors. - fmt:formatDate becomes the #temporals utility, which also supports java.time types. - Tag files and includes become th:replace and th:insert fragments, or the layout dialect. Because Thymeleaf templates live on the classpath, the application can go back to an ordinary executable JAR, run with java -jar, and does not need Tomcat Jasper or a ServletInitializer. You can migrate page by page: keep both technologies only temporarily, and remember that two view technologies in one application make it harder to tell which one renders a view.

Common Mistakes with JSP in Spring Boot

1. Packaging the project as a JAR. JSP views are then not rendered: you see a 404, or the browser downloads the .jsp file. Use war packaging. 2. Leaving out tomcat-embed-jasper, or running from an IDE without provided-scope dependencies on the classpath. Without the Jasper engine the container treats the .jsp file as a plain file, with the same symptom. 3. Putting JSP files in src/main/resources/templates. That folder is the Thymeleaf location. JSPs must be under src/main/webapp/WEB-INF/views, or whichever prefix you configured. 4. Getting the view prefix wrong. With the prefix /WEB-INF/views/ and the suffix .jsp, the view name users/list must match the file /WEB-INF/views/users/list.jsp exactly, including case. 5. Using the old JSTL URIs on Spring Boot 3. An error saying the absolute uri http://java.sun.com/jsp/jstl/core cannot be resolved means the JSTL version expects jakarta.tags.core, or the JSTL dependency is missing. 6. Using @RestController for page controllers. The view name is then returned as plain text. 7. Printing user input with a bare EL expression. Use c:out so the value is HTML-escaped. 8. Hard-coding absolute URLs or form actions. Behind a context path they miss the application. Use c:url or servletRelativeAction. 9. A form object that is a record, a missing form object in the GET handler, or a BindingResult that does not directly follow the @Valid parameter. 10. A controller mapped to /users/list that returns the view name users/list. Spring reports a circular view path, because the view name equals the request path. 11. Naming your own resolver bean viewResolver, which disables Spring Boot's content-negotiating resolver. 12. Deploying a Spring Boot 3 WAR to Tomcat 9. It needs Tomcat 10.1 or later. 13. Having the Thymeleaf and JSP starters on the same classpath, which makes it hard to tell which resolver renders a view.

FAQ

Does Spring Boot support JSP? Yes, but only with WAR packaging and Tomcat (not Undertow). JSPs are not supported in an executable JAR. Can I still run the WAR with java -jar? Yes. An executable WAR starts embedded Tomcat and serves JSPs. The ServletInitializer is only needed for deployment to an external container. Why do I get a 404 or see the raw JSP source? Usually the project is packaged as a JAR, Jasper is missing from the classpath, or the view prefix does not match the file location. Which JSTL taglib URI should I use? On Spring Boot 3 (Jakarta Tags 3.0) use jakarta.tags.core, jakarta.tags.fmt and jakarta.tags.functions. On Spring Boot 2 (JSTL 1.2) use the http://java.sun.com/jsp/jstl/... URIs. Should I use JSP for a new project? Generally no. Thymeleaf or FreeMarker work in an ordinary executable JAR, escape output by default, and are better supported by Spring Boot. Use JSP for migrations or WAR-based enterprise deployments. How do I handle error pages with JSP? A custom error.jsp does not replace Spring Boot's default error view. Use Spring Boot's custom error page support or a @ControllerAdvice that returns an explicit view name. How do I avoid restarting after editing a JSP? Set server.servlet.jsp.init-parameters.development=true in development so Jasper recompiles changed pages.