Customize Your Error Pages in Quarkus: Because 500 Deserves Better Than “Oops!”
Give your users something better than a blank 500 screen. Learn how to handle errors like a pro with HTML templates, content negotiation, and clean JSON responses in Quarkus.
If you’ve ever been greeted by a bland “Internal Server Error” message, you know how frustrating it can be for both users and developers. In Quarkus, we don’t have to settle for generic error pages. With a few well-placed ExceptionMapper
s and some stylish Qute templates, you can give your users a more helpful, branded, and secure experience when things go sideways.
In this tutorial, you’ll learn how to catch and customize common HTTP error responses like 404 and 500 using Quarkus RESTEasy Classic and quarkus-rest-qute
. We'll support both API clients (with JSON responses) and browser-based users (with pretty HTML pages).
Setting the Stage
Let’s get the boilerplate out of the way. Start with a new Quarkus project and make sure you’ve got the right extensions:
mvn io.quarkus.platform:quarkus-maven-plugin:3.22.3:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=custom-error-pages-classic \
-Dextensions="rest-qute,rest-jackson"
cd custom-error-pages-classic
You’re using quarkus-qute
for HTML templating and quarkus-jackson
for JSON.
Why Bother Customizing Error Pages?
Because production-ready apps need more than a stack trace dump.
Clarity: Users shouldn’t need to understand HTTP 500 semantics.
Branding: Keep your style intact, even in failure.
Security: Prevent leaking internal stack traces.
UX: Guide users to take next steps—reload, retry, or contact support.
1. Handling Generic 500 Errors with Style
When your app hits an unhandled exception, Quarkus returns a Quarkus styled 500 by default. Let’s replace that with a nicely styled HTML page or a clean JSON payload if the client prefers it.
Step 1: Design Your Error Page
Create this Qute template at src/main/resources/templates/errors/500.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Server Error</title>
</head>
<body>
<h1>Oops! Something went wrong on our end.</h1>
<p>Error Reference: {reference}</p>
{#if devMode}
<p><b>Exception:</b> {exceptionType}</p>
<p><b>Message:</b> {exceptionMessage}</p>
{/if}
</body>
</html>
Step 2: Create the Exception Mapper
Now let’s tell Quarkus how to respond when things break. Add this to org.acme.errorhandling.GenericExceptionMapper
:
@Provider
@ApplicationScoped
public class GenericExceptionMapper implements ExceptionMapper<Throwable> {
private static final Logger LOG = Logger.getLogger(GenericExceptionMapper.class);
@Inject
@Location("errors/500.html")
Template error500Page;
@Context
UriInfo uriInfo;
@Context
HttpHeaders headers;
@Override
public Response toResponse(Throwable exception) {
UUID errorId = UUID.randomUUID();
LaunchMode.current();
boolean devMode = LaunchMode.isDev();
LOG.errorf(exception, "Unhandled exception (ID %s) at %s", errorId, uriInfo.getPath());
List<MediaType> acceptableMediaTypes = headers.getAcceptableMediaTypes();
boolean onlyJsonIsAcceptable = false;
if (acceptableMediaTypes.size() == 1) {
// If the list has exactly one item, check if it's compatible with JSON
onlyJsonIsAcceptable = MediaType.APPLICATION_JSON_TYPE.isCompatible(acceptableMediaTypes.get(0));
}
boolean prefersJson = onlyJsonIsAcceptable || uriInfo.getPath().startsWith("/api/");
if (prefersJson) {
return Response.status(500)
.entity(new ErrorResponse(errorId.toString(), "An unexpected error occurred.", 500,
devMode ? exception.getClass().getName() : null,
devMode ? exception.getMessage() : null))
.type(MediaType.APPLICATION_JSON)
.build();
}
return Response.status(500)
.entity(error500Page
.data("reference", errorId.toString())
.data("exceptionType", exception.getClass().getName())
.data("exceptionMessage", exception.getMessage())
.data("devMode", devMode))
.type(MediaType.TEXT_HTML)
.build();
}
public static class ErrorResponse {
public String errorId, message, exceptionType, exceptionMessage;
public int statusCode;
public ErrorResponse(String id, String msg, int code, String type, String detail) {
this.errorId = id;
this.message = msg;
this.statusCode = code;
this.exceptionType = type;
this.exceptionMessage = detail;
}
}
}
2. Triggering and Testing Errors
Let’s simulate some failure. Modify the example GreetingResource
endpoint in org.acme
:
@Path("/")
public class GreetingResource {
@GET
@Path("/")
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "Hello from Quarkus";
}
@GET
@Path("/error")
public String cause500() {
throw new RuntimeException("Simulated failure!");
}
@GET
@Path("/api/error")
@Produces(MediaType.APPLICATION_JSON)
public String apiError() {
throw new RuntimeException("Simulated API error!");
}
Fire up the app: ./mvnw quarkus:dev
. Then visit:
http://localhost:8080/error → HTML error
curl -H "Accept: application/json" http://localhost:8080/api/error
→ JSON error
If you access an endpoint with your browser you might see log messages complaining about an Unhandled exception (ID 32df42f7-b93d-4fd8-a4e5-936f874d5b8e) at /favicon.ico. That looks like a 404 and not a 500.
3. Handling 404 Not Found Errors
Unmapped endpoints trigger NotFoundException
. Let’s catch it too.
Step 1: Create templates/errors/404.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Page Not Found</title>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>Sorry, the page you are looking for (<code>{path}</code>) could not be found.</p>
<p><a href="/">Go to Homepage</a></p>
</body>
</html>
Step 2: Create NotFoundExceptionMapper
@Provider
@ApplicationScoped
public class NotFoundExceptionMapper implements ExceptionMapper<NotFoundException> {
@Inject
@Location("errors/404.html")
Template notFoundPage;
@Context UriInfo uriInfo;
@Context HttpHeaders headers;
@Override
public Response toResponse(NotFoundException exception) {
String path = uriInfo.getPath();
boolean prefersJson = headers.getAcceptableMediaTypes().stream()
.anyMatch(MediaType.APPLICATION_JSON_TYPE::isCompatible)
|| path.startsWith("/api/");
if (prefersJson) {
return Response.status(404)
.entity(new GenericExceptionMapper.ErrorResponse(
null, "Resource not found at path: " + path, 404, null, exception.getMessage()))
.type(MediaType.APPLICATION_JSON)
.build();
}
return Response.status(404)
.entity(notFoundPage.data("path", path))
.type(MediaType.TEXT_HTML)
.build();
}
}
With your application still running, navigate to a non-existent REST resource URL, for example, http://localhost:8080/this/page/does/not/exist
. You should see your custom 404 page. Try http://localhost:8080/api/nonexistent
to see the JSON 404 response.
4. Adding a Custom Exception
You can create mappers for any specific exception, including jakarta.ws.rs.WebApplicationException
and its subclasses (like ForbiddenException
, BadRequestException
, etc.), or your own custom application exceptions.
Create MyCustomApplicationException.java
:
package org.acme.errorhandling;
public class MyCustomApplicationException extends RuntimeException {
public MyCustomApplicationException(String message) {
super(message);
}
}
Then add a mapper for it:
package org.acme.errorhandling;
import java.util.UUID;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.MediaType;
import org.jboss.logging.Logger;
@Provider
@ApplicationScoped
public class MyCustomApplicationExceptionMapper implements ExceptionMapper<MyCustomApplicationException> {
private static final Logger LOG = Logger.getLogger(MyCustomApplicationExceptionMapper.class);
@Override
public Response toResponse(MyCustomApplicationException exception) {
LOG.warnf("Handling MyCustomApplicationException: %s", exception.getMessage());
// Example: Return JSON response for this custom error
GenericExceptionMapper.ErrorResponse errorResponse = new GenericExceptionMapper.ErrorResponse(
"custom-" + UUID.randomUUID().toString().substring(0, 8),
"A custom application error occurred: " + exception.getMessage(),
Response.Status.BAD_REQUEST.getStatusCode(), // Or any other appropriate status
exception.getClass().getSimpleName(),
exception.getMessage());
return Response.status(Response.Status.BAD_REQUEST)
.entity(errorResponse)
.type(MediaType.APPLICATION_JSON) // Default to JSON for this example
.build();
}
}
Add a trigger method to your GreetingResource.java:
@GET
@Path("/custom-error")
@Produces(MediaType.APPLICATION_JSON)
public String triggerCustomError() {
throw new MyCustomApplicationException("This is a test of the custom application exception.");
}
Try it: http://localhost:8080/custom-error
5. Testing It All
Here’s how you might verify everything using REST Assured in a @QuarkusTest
.
Create src/test/java/org/acme/ErrorPagesTest.java
:
package org.acme;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@QuarkusTest
public class ErrorPagesTest {
@Test
public void testCustom404PageHtml() {
given()
.accept(MediaType.TEXT_HTML) // Explicitly request HTML
.when().get("/this/path/does/not/exist")
.then()
.statusCode(Response.Status.NOT_FOUND.getStatusCode())
.contentType(containsString("text/html")) // Check content type contains text/html
.body(containsString("Page Not Found")) // Check for content from your 404.html
.body(containsString("<code>/this/path/does/not/exist</code>"));
}
@Test
public void testCustom404PageJson() {
given()
.accept(MediaType.APPLICATION_JSON) // Explicitly request JSON
.when().get("/api/this/path/does/not/exist") // Use an API path
.then()
.statusCode(Response.Status.NOT_FOUND.getStatusCode())
.contentType(containsString("application/json"))
.body("message", containsString("Resource not found at path: /api/this/path/does/not/exist"))
.body("statusCode", is(404));
}
@Test
public void testCustomApplicationErrorJson() {
given()
.accept(MediaType.APPLICATION_JSON)
.when().get("/custom-error")
.then()
.statusCode(Response.Status.BAD_REQUEST.getStatusCode()) // As defined in MyCustomApplicationExceptionMapper
.contentType(containsString("application/json"))
.body("message", containsString("This is a test of the custom application exception."))
.body("exceptionType", is("MyCustomApplicationException"));
}
}
Add similar tests for 500 HTML, 500 JSON, and your custom error.
You can also run these tests in continuous testing mode! Give it a try by navigating to http://localhost:8080/q/dev-ui/continuous-testing
:
6. Pro Tips
Show stack traces only in dev mode. Pass to Qute template and check before rendering:
{#if devMode}
.Keep messages helpful but non-technical.
Escape everything that comes from the user.
Internationalize templates with Qute message bundles if needed.
Prefer lightweight, fast-loading templates—your error page shouldn’t crash too!
Wrapping Up
Custom error handling in Quarkus isn’t just about polish, it’s about clarity, security, and professionalism. With ExceptionMapper
, Qute, and a little content negotiation magic, your app can guide users gracefully through even the worst-case scenarios.
Now go forth and make 404s less boring and 500s less scary. As usual, you can find the above source code in a working example on my Github repository!