Building Grails custom error pages for every HTTP status code

Grails ships with a sensible default error handling setup, but as soon as an application grows beyond a hobby project the stock white-on-black stack trace becomes more of a liability than a feature. Visitors land on a page that reveals your internal class names, database queries, and occasionally framework version numbers — all the things a curious attacker or a confused customer can use to map out your stack. Replacing these defaults with thoughtful, branded error pages for every HTTP status code you actually serve is one of the highest-leverage polish tasks in any Grails project.

The good news is that Grails gives you several layers to work with: the UrlMappings DSL for declarative handling, the errors view resolver, controller-level exception resolvers, and Spring's underlying error attribute machinery. Knowing which layer to reach for at each status code — 400 versus 403 versus 404 versus 500 — is what separates a polished application from one that looks half-finished. The patterns below work whether you deploy to a bare Tomcat instance in a Brisbane data centre or to AWS Sydney, and they do not depend on any third-party plugin.

Why error pages matter beyond the default 404

Most teams treat error pages as an afterthought, mostly because the 404 case is the only one that ever gets exercised during happy-path development. The reality is far messier. A Grails REST endpoint that returns 422 for a validation failure, a controller that throws AccessDeniedException for 403, or a service that times out and bubbles up 503 — each of these produces a different raw response, and each one shows up in production logs the moment your traffic profile starts to resemble that of a real Australian SaaS serving users from Perth to Hobart.

Beyond the obvious branding concerns, error pages are also a legal and accessibility concern. Australian organisations operating under the Privacy Act 1988 and the Australian Privacy Principles are expected to handle user data requests responsibly, and a generic stack trace that leaks an email address or a session token is a notifiable data breach waiting to happen. The ACSC publishes the Essential Eight mitigation strategies, and replacing verbose error output is squarely inside the broader family of hygiene advice — small, unglamorous, and quietly effective at reducing the blast radius of a leaked endpoint.

Mapping HTTP status codes to Grails controllers

The cleanest place to start is the UrlMappings.groovy file. Grails lets you declare a catch-all mapping that forwards to a single controller action, and you can branch on the response status inside that action. A typical mapping looks like this:

"500"(controller: "errors", action: "handle")
"503"(controller: "errors", action: "handle")
"404"(controller: "errors", action: "handle")
"403"(controller: "errors", action: "handle")
"400"(controller: "errors", action: "handle")

Inside the ErrorsController you read the resolved status from the request attributes that Spring exposes, then dispatch to the right view. The trick is to never hard-code a mapping per status inside the controller — instead, build a small switch that returns the view name based on the status code, falling back to a generic error view when the code is outside the expected set. This keeps the controller compact and makes it trivial to add a new status, say 451 for legal blocks, without touching half a dozen files.

If you are building a JSON API alongside the GSP pages, the same controller can branch on the Accept header. A request from a single-page application gets a structured {status, message, requestId} payload, while a browser request renders the matching .gsp template. Many teams in Sydney and Melbourne that ship both web and mobile clients use this pattern because it removes the need for separate error controllers and lets one team own the whole response surface.

Creating dynamic error views with GSP

GSP templates behave much like any other view in Grails, with the added benefit that they have access to the same beans, locales, and tag libraries as your regular pages. A practical pattern is to keep one _layout.gsp that handles the chrome — header, footer, navigation — and have individual error templates extend it. The status code is passed in as a model variable, which lets you share a single contact form partial across every error view while still letting each page tell the user specifically what went wrong.

The view layer is also the right place to surface useful information without giving away too much. A 404 page can suggest three or four popular destinations on your site. A 403 page can explain how to request access. A 500 page should show a correlation ID that the user can quote when they contact support, and that ID should match the one in your logs. If you want a worked example of an authenticated flow that returns its own structured errors, the JWT authentication walkthrough shows the same correlation-ID pattern in a token-based context.

Localisation matters here. Australia is a country where AEST and AEDT alternate, and your error pages should pick up the user's locale so that a user in Adelaide sees the right date format when their request ID was generated. Storing your user-facing strings in messages.properties and providing an messages_en_AU.properties variant is the cheapest way to look professional, and it also helps when you eventually localise for en-NZ or other regional markets.

Handling redirects versus forwards for clean URLs

A subtle but important decision is whether your error controller should forward to a view or redirect to a URL. Forwarding preserves the original URL in the browser's address bar, which is the correct behaviour for 404 and 403 — the user typed or followed a bad link and you should not silently rewrite their location. Redirecting is better when the error has been resolved server-side and you want the user to land on a different resource, for example after a session timeout where you redirect to the login page with a continue parameter.

Grails makes this distinction natural through the forward and redirect controller methods. Use forward(controller: 'errors', action: 'handle', params: [status: request.forwardedRequest?.getAttribute('javax.servlet.error.status_code')]) when you want to keep the URL stable, and redirect(uri: '/login') when you genuinely want the address bar to change. Mixing the two up is a common source of confused analytics: every 404 logs as a hit on the error controller's URL, breaking funnel reports that the marketing team in your Sydney office is running on the same dataset.

For server-side error conditions that you want to keep out of the URL entirely, consider rendering a static file from /grails-app/views/errors/ and configuring your servlet container — Tomcat, Jetty, or the embedded one — to serve it directly. This is the lowest-latency option and it survives even when the Grails application context fails to start, which is exactly when you most want a polite error page.

Logging errors and notifying on-call engineers

A custom error page is only half the story; the other half is making sure the right people find out when something has gone wrong. Grails uses Logback by default, and the grails-app/conf/logback.groovy file is where you wire up structured JSON logging suitable for shipping to a centralised platform. Pair the log statement with a correlation ID generated in a filter, and you get full traceability from the user's browser through your application logs and into any downstream APM tool.

For teams running on-call rotations out of Melbourne or Brisbane, the next step is hooking the logger into an alerting pipeline. PagerDuty, Opsgenie, and the homegrown tooling used by companies such as Atlassian in Sydney all accept webhooks, and a simple Logback appender can POST critical events to whichever platform you use. The pattern is to filter at the appender level rather than at the application level, so that an emergency can be escalated even when the controller code that would normally handle it is itself broken.

Privacy-aware logging is especially relevant under the Notifiable Data Breaches scheme. A stack trace that includes a customer's email address or a credit card fragment can turn a routine 500 into an obligation to notify the Office of the Australian Information Commissioner. Grails' default Logback configuration masks passwords and credit card numbers, but custom fields require their own masking filter. A small MaskingFilter that redacts anything matching a credit-card or email regex keeps you on the right side of the regulations without forcing every developer to remember the rule.

Designing accessible error pages for Australian users

Error pages are some of the most neglected pages in terms of accessibility, yet they are exactly the pages a frustrated user is most likely to encounter. Following the Web Content Accessibility Guidelines (WCAG) 2.2 — which Australian government agencies are required to meet under the Disability Discrimination Act — means more than slapping a colour contrast ratio on a page. Focus order must be logical, screen readers must announce the error status, and forms on the page (such as a "report this problem" widget) must be fully labelled.

In practice, this means using semantic HTML (<main>, <h1> for the status code, <p> for the explanation), providing a "skip to main content" link at the top, and making sure that any error message is associated with the corresponding input via aria-describedby. The same rules apply to mobile users, who form a growing share of traffic for many Australian e-commerce sites. A page that fails to work on a mid-range Android handset in regional Queensland is not a page that has been designed for Australia.

One often-overlooked detail is the lang attribute on the HTML element. If your error pages are localised, the <html lang="en-au"> declaration helps assistive technology pick the right pronunciation dictionary, and it makes it obvious to search engines that the content is targeted at the Australian market rather than the en-US default.

Testing and deploying across Sydney and Melbourne cloud regions

Once the error handling code is in place, it has to be tested. Grails integration tests can simulate each status code by issuing requests with the appropriate Accept headers or by directly invoking the controller action with a mocked response. Pair those with browser-based tests using Selenium or Playwright to verify that the rendered HTML actually contains the expected copy and structure. The Grails test mixin's request.method = 'GET' and response.status = 404 shortcuts let you cover the common cases in a handful of lines.

Deployment is where a lot of error-handling work falls apart. The AWS Sydney region (ap-southeast-2) and the Melbourne region (ap-southeast-4) both run identical service catalogues, but they handle health checks and load-balancer error pages differently. AWS, for example, lets you supply custom error responses at the CloudFront or ALB layer, and these will be served when the underlying instance is unhealthy — exactly the moment when your Grails error controller cannot run. Set up those layer-level custom responses first, and let the application-level pages handle everything else.

For teams using Kubernetes in Australia, the equivalent is the Ingress controller's custom-error-pages feature, available in nginx-ingress and Traefik. Configuring it ensures that 502 and 504 responses from a failing pod still get branded copy rather than the upstream default. A small Helm chart that pins this configuration alongside your deployment makes it reproducible across environments and stops the inevitable drift between staging in Melbourne and production in Sydney.


Custom error pages are one of those features that nobody notices when they work and everybody notices when they do not. If you want to keep learning about the practical side of running Grails in production — from authentication patterns to deployment recipes — the Grails Example magazine publishes new issues every month with worked examples, Australian case studies, and reader questions answered in depth.