Error Handling & Flash Messages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1) and Java 21, checked on 2026-10-08, and every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, 22 new tests using MockMvc and a real embedded Tomcat on a random port); the output shown is the real output of those runs. Error handling and flash attributes are features of Spring MVC (servlet stack): the error templates are ordinary Thymeleaf templates, but the /error mapping, @ControllerAdvice, ProblemDetail and RedirectAttributes belong to Spring Boot and Spring Framework, so nothing here applies to standalone Thymeleaf, and Spring WebFlux has its own error handling (a different mechanism, outside this page).

A server-rendered application fails in two ways that the user must see handled well. A request can fail, and then the visitor needs a page in the look of the site with the right HTTP status, not a stack trace or the framework’s default page. A request can also succeed, and then the visitor needs to be told so after the redirect that follows a POST. This page covers both, continuing the Bookshelf scenario: Boot’s /error machinery and its error templates, the error attributes and the properties that control what leaks into them, @ControllerAdvice classes that turn a missing book into a 404 page and an optimistic-locking failure into a 409 page, the choice between an HTML error and a ProblemDetail JSON document, and flash attributes with an alert and a toast fragment.

How Spring Boot handles an error

Two different mechanisms cooperate, and keeping them apart explains most of the surprises.

  • Exception resolution inside Spring MVC. When a controller throws, DispatcherServlet asks its HandlerExceptionResolver chain. The first resolver that returns a result wins: @ExceptionHandler methods (local to the controller, then in @ControllerAdvice classes), @ResponseStatus-annotated exceptions, and the framework’s own mapping of its exceptions (NoResourceFoundException to 404, a type mismatch to 400, an unsupported method to 405). The handler method renders a response, view or body, by itself.

  • The servlet container’s error dispatch. If nothing resolved the exception, or something called response.sendError(...), the container forwards the request to the error page registered by Spring Boot, /error (spring.web.error.path). BasicErrorController serves it: for text/html it resolves an error view, for everything else it returns the error attributes as JSON.

flowchart TD req["Request"] --> ctl["Controller throws
or no handler matches"] ctl --> res{"HandlerExceptionResolver chain:
an @ExceptionHandler
or @ResponseStatus matches?"} res -->|yes| own["The handler renders its own
view or body with its status"] res -->|no| send["response.sendError(status)"] send --> disp["Container ERROR dispatch
to /error"] disp --> sec["Security filter chain runs again
/error must be permitted"] sec --> bec["BasicErrorController
builds the error attributes"] bec --> acc{"Accept header"} acc -->|text/html| view["ErrorViewResolver:
error/404, error/4xx,
error/5xx, then error"] acc -->|anything else| json["JSON error attributes"]

Because the second path is a second dispatch of the request, the whole filter chain, Spring Security included, sees it again. That is the reason the security configuration below has a line for /error.

Error templates

Boot looks for a view for the status code, trying the most specific name first. The files are searched in templates/error/ (a Thymeleaf template when the Thymeleaf starter is present) and in the static directories, such as public/error/, as plain HTML.

Table 1. How the error view is chosen for a status
Order File looked up (for a 404) Matches

1

templates/error/404.html

Exactly that status code

2

templates/error/4xx.html

Any status in the series, here every 4xx; for a 503 it is 5xx.html

3

templates/error.html (a view named error)

Every error without a more specific file; the fallback

4

the built-in "whitelabel" page

Used when none of the above exists; turn it off with spring.web.error.whitelabel.enabled=false

For routing that this naming cannot express, a bean implementing ErrorViewResolver can return any ModelAndView for a status, and ErrorController or BasicErrorController can be replaced or extended for new content types. The three templates below use the Layout Dialect decoration of the earlier pages (the layout is shown in Layouts & the Layout Dialect) and a message bundle for the texts.

templates/error/404.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}">
<head><title th:text="#{error.404.title}">Page not found</title></head>
<body><main layout:fragment="content">
<h1 th:text="#{error.404.title}">Page not found</h1>
<p>Status <span th:text="${status}">404</span> for <code th:text="${path}">/missing</code></p>
<a th:href="@{/books}" th:text="#{books.title}">Books</a>
</main></body></html>
templates/error/5xx.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}">
<head><title th:text="#{error.title}">Something went wrong</title></head>
<body><main layout:fragment="content">
<h1 th:text="#{error.title}">Something went wrong</h1>
<p>Status <span th:text="${status}">500</span> (<span th:text="${error}">Internal Server Error</span>)</p>
<p th:if="${supportCode != null}">Support code: <strong th:text="${supportCode}">1a2b3c4d</strong></p>
<p th:if="${message != null and !#strings.isEmpty(message)}" th:text="${message}">Only present when the properties allow it</p>
</main></body></html>
templates/error.html, the fallback
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}">
<head><title th:text="#{error.title}">Something went wrong</title></head>
<body><main layout:fragment="content">
<h1 th:text="#{error.title}">Something went wrong</h1>
<p>Status <span th:text="${status}">400</span> (<span th:text="${error}">Bad Request</span>) at
   <time th:text="${#dates.format(timestamp, 'yyyy-MM-dd HH:mm')}">2026-10-08 10:15</time></p>
</main></body></html>

Every value comes out through th:text, so it is HTML-escaped (see Text & Attribute Setting), and the prototype text in each tag keeps the file valid when opened from disk. Requesting an unknown URL as a logged-in reader with an Accept: text/html header, and a malformed id, gives (the full pages, from the run):

Rendered 404 for GET /no-such-page (status 404, text/html)
<!DOCTYPE html>
<html>
<head>
	<title>Page not found - Bookshelf</title>
</head>
<body>

<main>
<h1>Page not found</h1>
<p>Status <span>404</span> for <code>/no-such-page</code></p>
<a href="/books">Libros</a>
</main>
</body>
</html>
Rendered fallback error.html for GET /books/abc (status 400, no error/400.html or error/4xx.html exists)
<main>
<h1>Something went wrong</h1>
<p>Status <span>400</span> (<span>Bad Request</span>) at
   <time>2026-10-08 00:42</time></p>
</main>
Rendered 5xx.html for an unexpected IllegalStateException in /boom (status 500)
<main>
<h1>Something went wrong</h1>
<p>Status <span>500</span> (<span>Internal Server Error</span>)</p>
<p>Support code: <strong>d0d600ae</strong></p>

</main>

The exception’s text, secret internal detail in the test, appears nowhere in the page: the tests assert it. The same URL requested without the HTML preference gets the attributes as JSON from the same controller:

curl -H 'Accept: application/json' http://localhost:8080/no-such-page
Response (404, application/json)
{"timestamp":"2026-10-07T22:47:48.782Z","status":404,"error":"Not Found","path":"/no-such-page","supportCode":"6fe74b2d"}

Error attributes and the spring.web.error properties

BasicErrorController asks an ErrorAttributes bean for a map, and that map is both the JSON body and the model of the error view, which is why ${status}, ${error} and ${path} work in the templates above.

Table 2. The attributes of DefaultErrorAttributes
Attribute Value Present

timestamp

The moment of the error, a java.util.Date in the view model (use #dates.format) and an ISO-8601 string in the JSON

Always

status

The numeric HTTP status

Always

error

The reason phrase, such as Internal Server Error

Always

path

The request URI that failed

By default (spring.web.error.include-path)

exception

The class name of the exception

Only with spring.web.error.include-exception=true

message

The exception message, or the sendError message

Per spring.web.error.include-message; an empty string when excluded

errors

The validation errors of a binding failure

Per spring.web.error.include-binding-errors

trace

The stack trace as text

Per spring.web.error.include-stacktrace

The properties that control them have moved. In Spring Boot 4.0 the server.error.* family was renamed spring.web.error.* (the Boot 4.1.1 metadata marks the old names deprecated at the error level, with the new names as their replacement), because error handling is no longer tied to the embedded server:

Table 3. The error properties, defaults of Spring Boot 4.1.1
Property (old name in Boot 3) Default Effect

spring.web.error.path (server.error.path)

/error

Where the container forwards errors; the controller that serves it must exist

spring.web.error.include-message (server.error.include-message)

never

never, always or on-param: show message only when the request has a message parameter

spring.web.error.include-binding-errors (server.error.include-binding-errors)

never

Same three values, request parameter errors

spring.web.error.include-stacktrace (server.error.include-stacktrace)

never

Same three values, request parameter trace

spring.web.error.include-exception (server.error.include-exception)

false

Adds the exception class name

spring.web.error.include-path (server.error.include-path)

always

Adds path

spring.web.error.whitelabel.enabled (server.error.whitelabel.enabled)

true

The built-in HTML page when no template matches

The defaults are the safe ones, so the application configuration only needs to say what it relies on:

spring.web.error.include-message=never
spring.web.error.include-stacktrace=never
spring.web.error.include-binding-errors=never
spring.web.error.include-exception=false

The effect of loosening them was checked on the running application. With spring.web.error.include-message=always, include-exception=true and include-stacktrace=on-param, the unexpected exception of /boom becomes visible, and a ?trace=true request adds the stack trace to the JSON:

{"timestamp":"2026-10-07T22:42:43.183Z","status":500,"error":"Internal Server Error","exception":"java.lang.IllegalStateException","message":"secret internal detail","path":"/boom","supportCode":"b8380d4b"}

That is exactly what must not reach a visitor in production: messages and stack traces describe the code, the SQL or the file system. Leave the three include-* properties at their defaults outside local development, log the details on the server instead, and give the user a support code to quote. A custom ErrorAttributes does that: extending DefaultErrorAttributes and replacing it keeps all the normal attributes and adds one.

@Component
class SupportCodeErrorAttributes extends DefaultErrorAttributes {
    private static final Logger log = LoggerFactory.getLogger(SupportCodeErrorAttributes.class);

    @Override
    public Map<String, Object> getErrorAttributes(WebRequest request, ErrorAttributeOptions options) {
        Map<String, Object> attributes = super.getErrorAttributes(request, options);
        String code = UUID.randomUUID().toString().substring(0, 8);
        attributes.put("supportCode", code);
        Throwable error = getError(request);
        if (error != null) {
            log.error("Unhandled error, supportCode={}", code, error);
        }
        return attributes;
    }
}

The imports are org.springframework.boot.webmvc.error.DefaultErrorAttributes and org.springframework.boot.web.error.ErrorAttributeOptions (in Boot 3 the first one was in org.springframework.boot.web.servlet.error). The supportCode then appears in the model of 5xx.html and in the JSON, as in the outputs above. The same error is written to the log with the exception, under the same code.

A consequence of the rename deserves its own warning. Setting an old name in Boot 4.1 is silently ignored: with server.error.include-message=always in the environment, the same /boom request still answered

{"timestamp":"2026-10-07T22:42:15.710Z","status":500,"error":"Internal Server Error","path":"/boom","supportCode":"496a24eb"}

so an application migrated from Boot 3 keeps working but quietly stops showing what it showed (for example during development). Search the configuration for server.error when upgrading; spring-boot-properties-migrator reports the renames at startup.

Letting the error page through Spring Security

The error dispatch is filtered like any request. With the Bookshelf’s rule "everything except the login page and static resources needs authentication", the error page of an anonymous visitor is itself an unauthenticated request to /error, and the visitor is sent to the login form instead of seeing the 404 page.

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a
                .requestMatchers("/login", "/css/**", "/error").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}

The run requested the missing public URL /css/missing.css as an anonymous visitor. With /error in the permitted list the answer was the 404 page; with the entry removed the same request was redirected to /login. Only /error itself needs to be public: the original URL keeps its own rule, and CSRF protection stays on (the application never disables it; see Forms & Data Binding for the token in forms, and Spring Security).

@ControllerAdvice views for 404 and 409

Boot’s error page is the fallback for what nobody handled. For failures that the domain understands, a @ControllerAdvice is better: it runs inside the MVC dispatch, can pick a view and the status itself, and does not need the second dispatch. Two cases of the Bookshelf are typical. A book that does not exist is a 404, and a book edited by two people at once is a 409 (conflict).

The 404 starts with an exception that the services and controllers throw for a missing entity. Its message is for the log, never for the page:

public class BookNotFoundException extends RuntimeException {
    private final Long id;

    public BookNotFoundException(Long id) {
        super("Book " + id + " not found");
        this.id = id;
    }

    public Long getId() { return id; }
}

The 409 needs optimistic locking: a @Version field on the entity makes Hibernate add where version = ? to the update and fail when another transaction changed the row (see Transaction Isolation & Locking for the mechanism). The form carries the version it was loaded with in a hidden field, and the update builds a detached copy with it, so the merge checks the version:

@Entity
public class Book {
    @Id @GeneratedValue private Long id;
    @Version private Long version;
    @NotBlank private String title;
    private String author;
    // ...
}
@PostMapping("/{id}")
public String update(@PathVariable Long id, @Valid BookForm form, BindingResult result,
                     RedirectAttributes redirect) {
    if (result.hasErrors()) { return "books/form"; }
    if (!repo.existsById(id)) { throw new BookNotFoundException(id); }
    // the version comes from the hidden field; a stale one makes JPA throw
    // ObjectOptimisticLockingFailureException when the detached copy is merged
    repo.save(Book.detached(id, form.version(), form.title(), form.author()));
    redirect.addFlashAttribute("alert", Alert.success("book.updated", form.title()));
    return "redirect:/books";
}

The form is a record, record BookForm(@NotBlank String title, String author, Long version), rather than the entity, so that a request cannot set fields such as the id through data binding; the template side of the hidden version input is on the forms page. The advice maps the two exceptions to views:

@ControllerAdvice(assignableTypes = BookController.class)
class BookViewExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(BookViewExceptionHandler.class);

    @ExceptionHandler(BookNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    String notFound(BookNotFoundException ex) {
        log.info("Book {} not found", ex.getId());
        return "error/book-not-found";
    }

    @ExceptionHandler(OptimisticLockingFailureException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    String conflict(OptimisticLockingFailureException ex) {
        log.info("Concurrent update rejected", ex);
        return "error/conflict";
    }
}

The details that matter here:

  • The returned String is a fixed view name. It is never built from request data, and the exception is only logged; the template never receives ex.getMessage().

  • @ResponseStatus on the handler method sets the status of the response that renders the view. Without it the page would be returned with 200, which breaks caches, crawlers and htmx (it swaps only successful responses by default).

  • assignableTypes scopes the advice to the HTML controller. A bare @ControllerAdvice applies to every controller, including the REST ones of the next section; basePackages, basePackageClasses and annotations are the other selectors. See Spring MVC Integration for the global @ModelAttribute use.

  • OptimisticLockingFailureException is the Spring superclass of ObjectOptimisticLockingFailureException, so the handler also covers other persistence technologies.

  • If an advice class declares both a specific and a general handler, the most specific exception type wins; across several advice beans, @Order decides.

The two views are ordinary templates, decorated by the same layout:

<!-- templates/error/book-not-found.html -->
<main layout:fragment="content">
<h1 th:text="#{error.book.notfound}">That book does not exist.</h1>
<a th:href="@{/books}" th:text="#{books.title}">Books</a>
</main>

<!-- templates/error/conflict.html -->
<main layout:fragment="content">
<h1 th:text="#{error.conflict.title}">Someone else changed this book</h1>
<p th:text="#{error.conflict.text}">Your changes were not saved.</p>
<a th:href="@{/books}" th:text="#{books.title}">Books</a>
</main>

The test saves a book, updates it once with version 0 (accepted, the version becomes 1), then submits a second form that still carries version 0:

Book book = repo.save(new Book("Dune", "Frank Herbert"));
mvc.perform(post("/books/" + book.getId()).with(csrf())
        .param("title", "Dune (2nd ed.)").param("author", "Frank Herbert").param("version", "0"))
        .andExpect(status().is3xxRedirection());
mvc.perform(post("/books/" + book.getId()).with(csrf())
        .param("title", "Dune (other edit)").param("author", "Frank Herbert").param("version", "0"))
        .andExpect(status().isConflict())
        .andExpect(view().name("error/conflict"));
Rendered error/conflict view (status 409)
<main>
<h1>Someone else changed this book</h1>
<p>Your changes were not saved. Reload the book and apply them again.</p>
<a href="/books">Libros</a>
</main>

A request for /books/9999 renders error/book-not-found with status 404. In a real application, the 409 page would offer the current values next to the rejected ones, so the user can merge the edits instead of retyping them.

HTML errors or ProblemDetail

An HTML application often has a few JSON endpoints too (a search-as-you-type call, an htmx helper, a small API). Those clients cannot use an HTML page; they should receive a machine-readable error, and the standard format is Problem Details for HTTP APIs, RFC 9457 (application/problem+json), represented in Spring by ProblemDetail. The two kinds of error must not leak into each other: an HTML browser page must not receive JSON, and an API client must not receive a Thymeleaf page.

A @RestControllerAdvice limited to the REST controllers returns ProblemDetail objects; extending ResponseEntityExceptionHandler makes the framework’s own exceptions (a malformed body, a wrong parameter type) use the same format:

@RestController
@RequestMapping("/api/books")
class BookApiController {
    record BookResponse(Long id, String title, String author, Long version) { }

    private final BookRepository repo;
    BookApiController(BookRepository repo) { this.repo = repo; }

    @GetMapping("/{id}")
    BookResponse get(@PathVariable Long id) {
        Book b = repo.findById(id).orElseThrow(() -> new BookNotFoundException(id));
        return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getVersion());
    }
}
@RestControllerAdvice(annotations = RestController.class)
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    ProblemDetail notFound(BookNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "The book does not exist.");
        problem.setTitle("Book not found");
        problem.setType(URI.create("https://bookshelf.example/problems/book-not-found"));
        problem.setProperty("bookId", ex.getId());
        return problem;
    }

    @ExceptionHandler(OptimisticLockingFailureException.class)
    ProblemDetail conflict() {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The book was changed by someone else. Reload it and try again.");
        problem.setTitle("Concurrent update");
        problem.setType(URI.create("https://bookshelf.example/problems/concurrent-update"));
        return problem;
    }
}
Responses from the run
GET /api/books/9999        -> 404 application/problem+json
{"detail":"The book does not exist.","instance":"/api/books/9999","status":404,"title":"Book not found","type":"https://bookshelf.example/problems/book-not-found","bookId":9999}

PUT /api/books/2 (stale version) -> 409
{"detail":"The book was changed by someone else. Reload it and try again.","instance":"/api/books/2","status":409,"title":"Concurrent update","type":"https://bookshelf.example/problems/concurrent-update"}

PUT /api/books/1 with the body {oops -> 400
{"detail":"Failed to read request","instance":"/api/books/1","status":400,"title":"Bad Request"}

GET /api/books/abc         -> 400
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/books/abc","status":400,"title":"Bad Request"}

The type, title, status, detail and instance members are the RFC 9457 vocabulary and bookId is an extension member added with setProperty. A client can switch on type; the human-readable detail is written by the application and should not include internal data.

Why not just turn Boot’s switch on

Boot also has a property, spring.mvc.problemdetails.enabled (default false), that registers an advice making every Spring MVC exception a ProblemDetail. The run with the property set to true and no advice of our own shows the catch:

With spring.mvc.problemdetails.enabled=true, unknown URL, three different Accept headers
Accept: text/html          -> 404 application/problem+json
Accept: application/json   -> 404 application/problem+json
(no Accept header)         -> 404 application/problem+json
{"detail":"No static resource no-such-page.","instance":"/no-such-page","status":404,"title":"Not Found"}

The advice applies to all controllers and to the framework’s own NoResourceFoundException, so the browser no longer reaches error/404.html: the visitor gets a JSON document in place of the site’s page, and the detail text echoes the requested path. For an application that serves HTML, keep the property off and scope the API handling as above: with the scoped advice, /no-such-page was still answered by error/404.html (the 404 test above). Boot’s own advice is declared with a condition on a missing ResponseEntityExceptionHandler bean, so it backs off when the application defines one, as the scoped advice does.

Two more points from the runs:

  • @ControllerAdvice(annotations = Controller.class) also matches @RestController, because the annotation is searched including its meta-annotations. Use assignableTypes, basePackages or @RestController as the selector, as above.

  • A single URL can serve both audiences through content negotiation, because BasicErrorController has one method for text/html and one for everything else. That is what the 404 page and the JSON of curl -H 'Accept: application/json' earlier show for the same URL. Handlers that return a view or a ProblemDetail are chosen by the controller that threw, not by the Accept header: an HTML controller should not return JSON, and a REST controller should not return a view.

For the design of the REST endpoints themselves see REST APIs.

Flash attributes and the alert fragment

After a successful POST the controller redirects (the Post/Redirect/Get pattern), so that a refresh does not resubmit the form. The redirect starts a new request, and the message "Book saved" must survive exactly one such request. Two obvious options are wrong. A request attribute or a Model attribute is gone after the redirect. A query parameter such as redirect:/books?saved=Dune survives, but it is visible, bookmarkable and forgeable (anyone can craft a link that makes the site display text of their choosing).

Flash attributes are the mechanism made for this. RedirectAttributes.addFlashAttribute stores the value in a FlashMap kept in the HTTP session, tied to the redirect target; the next request that matches it receives the values as model attributes, and the map is then removed.

sequenceDiagram participant B as Browser participant C as BookController participant S as Session FlashMap B->>C: POST /books (CSRF token) C->>C: repo.save(book) C->>S: addFlashAttribute alert C-->>B: 302 Location /books B->>C: GET /books S-->>C: alert becomes a model attribute and is removed C-->>B: 200 list page with the alert B->>C: GET /books again (refresh) C-->>B: 200 list page without the alert

The message is a small value object, not a string, so that the level and the translation key travel with it. The fields are fixed sets; only the argument comes from the user’s data:

public record Alert(Level level, String code, String argument) {

    public enum Level { SUCCESS, INFO, WARNING, DANGER }

    public static Alert success(String code, String argument) {
        return new Alert(Level.SUCCESS, code, argument);
    }

    /** The CSS suffix, always one of a fixed set, never request data. */
    public String css() { return level.name().toLowerCase(); }

    /** Urgent messages are announced at once by assistive technology. */
    public boolean urgent() { return level == Level.DANGER; }
}

The controllers add it before redirecting. Deleting is a POST to its own URL (never a GET link: a crawler or a prefetch could delete data), and the entity is loaded first so that the message can name it:

@PostMapping
public String save(@Valid Book book, BindingResult result, RedirectAttributes redirect) {
    if (result.hasErrors()) { return "books/form"; }
    repo.save(book);
    redirect.addFlashAttribute("alert", Alert.success("book.saved", book.getTitle()));
    return "redirect:/books";
}

@PostMapping("/{id}/delete")
public String delete(@PathVariable Long id, RedirectAttributes redirect) {
    Book book = repo.findById(id).orElseThrow(() -> new BookNotFoundException(id));
    repo.delete(book);
    redirect.addFlashAttribute("alert", Alert.success("book.deleted", book.getTitle()));
    return "redirect:/books";
}

The messages live in the bundle, with a placeholder for the argument (the internationalisation page covers the bundles and locale resolution):

book.saved=Saved: {0}
book.updated=Updated: {0}
book.deleted=Deleted: {0}

The fragment renders the alert if the model contains one. #{${alert.code}(${alert.argument})} is a message expression whose key is itself an expression (a dynamic key) with a parameter; the argument is escaped by th:text, and the key comes from the controller, not from the request. A fragment file fragments/alerts.html holds two variants:

<div th:fragment="alert" th:if="${alert != null}"
     class="alert" th:classappend="'alert-' + ${alert.css}"
     th:attr="role=${alert.urgent} ? 'alert' : 'status'">
  <span th:text="#{${alert.code}(${alert.argument})}">Saved: Dune</span>
</div>

<div th:fragment="toast" class="toast-container" aria-live="polite">
  <div th:if="${alert != null}" class="toast" th:classappend="'toast-' + ${alert.css}">
    <span th:text="#{${alert.code}(${alert.argument})}">Saved: Dune</span>
  </div>
</div>

The layout includes it once, above the content, so that every page can show a flash message without repeating anything:

<body>
<div th:replace="~{fragments/alerts :: alert}"></div>
<main layout:fragment="content">Default</main>
</body>

The first fragment is an alert, a message inside the page flow: role="status" is announced politely, role="alert" (for the DANGER level) interrupts. The second is a toast: a container with aria-live="polite" that is always present in the page, which matters because assistive technology announces changes to an existing live region more reliably than the appearance of a new one. Positioning it in a corner and fading it out belongs in CSS (an animation with a delay), which keeps the page free of inline scripts and so compatible with a strict Content-Security-Policy. A dismiss button needs a little JavaScript; see Reusable UI Components for the simpler alert component that takes its text as a parameter, and Web Accessibility for live regions.

The test performs the save, reads the flash map, and renders the list with it:

The redirect and the flash map (from the run)
POST /books -> 302 Location: /books
flash map: FlashMap [attributes={alert=Alert[level=SUCCESS, code=book.saved, argument=Emma]}, targetRequestPath=/books, targetRequestParams={}]
The alert on the next GET /books
<div class="alert alert-success" role="status">
  <span>Saved: Emma</span>
</div>

A following GET /books does not contain the alert, and an argument containing markup is escaped:

Markup in the flashed argument is escaped
<div class="alert alert-success" role="status">
  <span>Saved: &lt;b&gt;x&lt;/b&gt;</span>
</div>

The delete buttons of the list are forms, and th:action adds the CSRF token automatically; the run confirms the hidden _csrf input is present, and that a GET of the delete URL is rejected with a 4xx:

<form th:action="@{/books/{id}/delete(id=${b.id})}" method="post">
  <button type="submit">Delete</button>
</form>

Things to know about flash attributes:

  • They are stored in the session by default (SessionFlashMapManager), so they follow the session: with several application instances, the session must be shared or sticky, as for login.

  • addFlashAttribute is for objects that are not part of the URL, while addAttribute appends a value to the redirect URL as a query parameter. Mixing them up leaks the message into the address bar.

  • A flash map is matched to the redirect target and expires after a short time, so it only works for a redirect to a page of the same application, followed promptly by the browser.

  • Only a full-page redirect consumes them. An htmx response that swaps a fragment has no redirect, so the message has to be part of the returned fragment (or sent with an out-of-band swap), which the htmx pages of this section show.

  • Show only translation keys and values the application chose; if the message text must contain user data, it goes through th:text, as above, never th:utext.

Pitfalls

Symptom Cause Fix

The custom 404.html is ignored and the whitelabel page appears

The file is not under templates/error/ (or public/error/), the name is not the status code or series, or the Thymeleaf starter is missing

Name it 404.html, 4xx.html, 5xx.html, or provide error.html

Anonymous visitors see the login form instead of the error page

/error is not permitted, so the error dispatch is redirected to the login

Add /error to permitAll, only that path

server.error.include-message=always has no effect after upgrading to Boot 4

The property was renamed spring.web.error.include-message; the old name is ignored

Use spring.web.error.*

Stack traces or exception messages appear in the page or JSON

spring.web.error.include-stacktrace, include-message or include-exception loosened, or the template prints ${exception.message}

Keep the defaults, log the details, show a support code, and use th:text only for the attributes shown above

The browser gets JSON for a missing page

spring.mvc.problemdetails.enabled=true applies to every controller and to NoResourceFoundException

Leave it off for HTML applications and scope a @RestControllerAdvice

An advice meant for HTML controllers also handles the REST ones

@ControllerAdvice without selectors applies to all controllers, and annotations = Controller.class matches @RestController

Use assignableTypes, basePackages or @RestController

The error page has the right look but status 200

An @ExceptionHandler returning a view without @ResponseStatus or a ResponseEntity

Add @ResponseStatus, or set the status on the response

ObjectOptimisticLockingFailureException is never thrown

No @Version field, or the update loads the entity and copies fields onto a managed instance, so the version is not compared with the form’s

Add @Version, keep the version in a hidden field and merge a detached copy as shown

The flash message appears twice, or not at all

The redirect target differs from the page that renders the alert, a second redirect happens first, or the layout does not include the fragment

Redirect once to the page that includes the alert fragment

The flash message shows in the address bar

addAttribute or a redirect:/books?saved=... was used

Use addFlashAttribute

A GET link deletes data and the error page cannot undo it

State change on a safe method

Delete with POST and a CSRF token, then redirect with a flash message

Version notes and the books

  • The servlet error handling classes live in new packages in Spring Boot 4.1.1: ErrorAttributes and DefaultErrorAttributes in org.springframework.boot.webmvc.error, ErrorViewResolver and BasicErrorController in org.springframework.boot.webmvc.autoconfigure.error, and ErrorAttributeOptions and ErrorPage in org.springframework.boot.web.error.

  • The server.error.* properties were renamed spring.web.error.* in Boot 4.0 and the old names are deprecated at the error level (4.1.1 metadata); they no longer bind.

  • ProblemDetail support is part of Spring Framework since 6.0, and spring.mvc.problemdetails.enabled has defaulted to false since its introduction; RFC 9457 replaced RFC 7807.

  • Taming Thymeleaf (Wim Deblauwe, consulted edition: PDF v1.1.1, 2021) covers the custom error pages under templates/error/ and a @ControllerAdvice for a conflict in its data editing chapter, and flash attributes for the delete confirmation in the chapter after it. The approach holds; take the property names (server.error.include-exception, server.error.include-stacktrace appear there) as spring.web.error.*, apply the advice with a selector instead of globally, and add the security rule for /error. The book predates the Boot 4 package names and does not cover ProblemDetail.