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,
DispatcherServletasks itsHandlerExceptionResolverchain. The first resolver that returns a result wins:@ExceptionHandlermethods (local to the controller, then in@ControllerAdviceclasses),@ResponseStatus-annotated exceptions, and the framework’s own mapping of its exceptions (NoResourceFoundExceptionto 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).BasicErrorControllerserves it: fortext/htmlit resolves an error view, for everything else it returns the error attributes as JSON.
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.
Source: Spring Boot — Error Handling
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.
| Order | File looked up (for a 404) | Matches |
|---|---|---|
1 |
|
Exactly that status code |
2 |
|
Any status in the series, here every |
3 |
|
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 |
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.
<!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>
<!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>
<!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>
Source: Spring Boot — Custom Error Pages
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):
<!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>
<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>
<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>
Source: Spring Boot — Custom Error Pages
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
{"timestamp":"2026-10-07T22:47:48.782Z","status":404,"error":"Not Found","path":"/no-such-page","supportCode":"6fe74b2d"}
Source: Spring Boot — Error Handling
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.
| Attribute | Value | Present |
|---|---|---|
|
The moment of the error, a |
Always |
|
The numeric HTTP status |
Always |
|
The reason phrase, such as |
Always |
|
The request URI that failed |
By default ( |
|
The class name of the exception |
Only with |
|
The exception message, or the |
Per |
|
The validation errors of a binding failure |
Per |
|
The stack trace as text |
Per |
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:
| Property (old name in Boot 3) | Default | Effect |
|---|---|---|
|
|
Where the container forwards errors; the controller that serves it must exist |
|
|
|
|
|
Same three values, request parameter |
|
|
Same three values, request parameter |
|
|
Adds the exception class name |
|
|
Adds |
|
|
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
Source: Spring Boot — Web Properties
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;
}
}
Source: Spring Boot — Error Handling
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();
}
}
Source: Spring Boot — Error Handling
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(). -
@ResponseStatuson 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). -
assignableTypesscopes the advice to the HTML controller. A bare@ControllerAdviceapplies to every controller, including the REST ones of the next section;basePackages,basePackageClassesandannotationsare the other selectors. See Spring MVC Integration for the global@ModelAttributeuse. -
OptimisticLockingFailureExceptionis the Spring superclass ofObjectOptimisticLockingFailureException, 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,
@Orderdecides.
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>
Source: Using Thymeleaf — Messages
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"));
<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;
}
}
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:
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. UseassignableTypes,basePackagesor@RestControlleras the selector, as above. -
A single URL can serve both audiences through content negotiation, because
BasicErrorControllerhas one method fortext/htmland one for everything else. That is what the 404 page and the JSON ofcurl -H 'Accept: application/json'earlier show for the same URL. Handlers that return a view or aProblemDetailare chosen by the controller that threw, not by theAcceptheader: an HTML controller should not return JSON, and a REST controller should not return a view.
Source: Spring Boot — Error Handling
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.
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}
Source: Using Thymeleaf — Messages
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:
POST /books -> 302 Location: /books
flash map: FlashMap [attributes={alert=Alert[level=SUCCESS, code=book.saved, argument=Emma]}, targetRequestPath=/books, targetRequestParams={}]
<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:
<div class="alert alert-success" role="status">
<span>Saved: <b>x</b></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. -
addFlashAttributeis for objects that are not part of the URL, whileaddAttributeappends 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, neverth:utext.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The custom |
The file is not under |
Name it |
Anonymous visitors see the login form instead of the error page |
|
Add |
|
The property was renamed |
Use |
Stack traces or exception messages appear in the page or JSON |
|
Keep the defaults, log the details, show a support code, and use |
The browser gets JSON for a missing page |
|
Leave it off for HTML applications and scope a |
An advice meant for HTML controllers also handles the REST ones |
|
Use |
The error page has the right look but status 200 |
An |
Add |
|
No |
Add |
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 |
|
Use |
A |
State change on a safe method |
Delete with |
Version notes and the books
-
The servlet error handling classes live in new packages in Spring Boot 4.1.1:
ErrorAttributesandDefaultErrorAttributesinorg.springframework.boot.webmvc.error,ErrorViewResolverandBasicErrorControllerinorg.springframework.boot.webmvc.autoconfigure.error, andErrorAttributeOptionsandErrorPageinorg.springframework.boot.web.error. -
The
server.error.*properties were renamedspring.web.error.*in Boot 4.0 and the old names are deprecated at the error level (4.1.1 metadata); they no longer bind. -
ProblemDetailsupport is part of Spring Framework since 6.0, andspring.mvc.problemdetails.enabledhas defaulted tofalsesince 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@ControllerAdvicefor 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-stacktraceappear there) asspring.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 coverProblemDetail.