Localising Grails views with message bundles
A Grails view often begins with English in headings, labels, paragraphs, and validation messages. That is fine for one audience, but changing wording or adding a language otherwise means editing the same markup repeatedly. Internationalisation (i18n) moves user-facing text into resource bundles, allowing one GSP to resolve suitable copy for each supported locale. Bundles also keep presentation logic, accessibility attributes, and translated wording in separate files.
Keep each message as a complete interface string rather than stitching translated fragments together. Reserve keys for semantic meaning, let translators control grammar and punctuation, and pass dynamic values as arguments. This approach gives an Australian application a dependable en-AU default while leaving room for other languages. It also creates a clear path from view extraction to translation, review, testing, and deployment without duplicating the page.
Build bundles around stable keys
Grails discovers translated messages placed under grails-app/i18n. The base messages.properties file is the fallback, so start with every key required by the page. Use readable, namespaced names instead of copying visible sentences into JavaScript or controller properties.
# grails-app/i18n/messages.properties
app.title=Pantry Ledger
nav.dashboard=Dashboard
expense.empty=No expenses have been recorded yet.
expense.add=Add expense
expense.tax=Tax
common.organised=Organized
A locale override belongs in a file named for its language and optional region. Java locale names use an underscore, which is why Australian English uses messages_en_AU.properties; language-only content can live in messages_en.properties. Missing entries normally fall back through the language file to the base bundle.
# grails-app/i18n/messages_en_AU.properties
expense.tax=GST (10%)
common.organised=Organised
settings.locale=Language and region
Keep keys such as expense.empty because they describe intent, not English layout. A translator can then change word order without touching the GSP. The Grails code examples are useful for comparing message tags and bundle layouts as an application grows.
Replace view copy without changing behaviour
Use the message tag where text is rendered. Positional arguments begin at zero, and the translator may move {0} within the sentence when the target grammar requires it.
<g:message code="app.title" />
<g:message code="dashboard.greeting" args="${[displayName]}" />
The matching bundle entry is dashboard.greeting=Welcome, {0}. Passing displayName as data keeps it out of the resource file and avoids rebuilding translated text. For controller-generated text, use Grails’ dynamic message facility with the same key rather than returning an English sentence from an action.
Some labels change according to application state. Select a key, then render that key; do not select an English phrase and attempt to translate it later.
def submitKey = invoice.paid ? 'invoice.action.receipt' : 'invoice.action.pay'
<g:message code="${submitKey}" />
The optional default attribute can make unfinished copy visible during development, but it is not a substitute for maintained translations. Keep normal GSP escaping for messages and arguments, particularly when values include names or notes. Translated text should not be made unescaped merely to permit HTML. Accessibility text belongs in the same localisation workflow as visible labels, including button names, error summaries, and image alternatives.
Format money, dates and counts
Keep translation separate from formatting. A message can supply Invoice total: {0}, while Java’s number formatter supplies the currency placement and separators for the active locale. Currency itself remains a business rule: an Australian invoice stays in AUD even when a user selects another interface language.
A controller can prepare an Australian amount for the view:
def locale = request.locale ?: Locale.forLanguageTag('en-AU')
def money = NumberFormat.getCurrencyInstance(locale)
money.currency = Currency.getInstance('AUD')
[totalText: money.format(invoice.total)]
Then render <g:message code="invoice.total" args="${[totalText]}" />. This produces a value such as $1,249.00 for en-AU without forcing the same symbol or punctuation onto every locale. Give formatted text an appropriate accessible label when assistive technology needs the full amount.
Base bundles do not automatically choose every grammatical plural form. For a small English interface, compute a stable key and let each bundle define that form:
def itemKey = cart.size() == 1 ? 'cart.item.one' : 'cart.item.other'
<g:message code="${itemKey}" args="${[cart.size()]}" />
Use cart.item.one=You have {0} item in the cart. and cart.item.other=You have {0} items in the cart.. Languages with several plural categories need a richer plural mechanism, such as ICU messages or a compatible plugin, rather than one simple zero-or-other branch.
Australian presentation has several concrete conventions. en-AU supports spelling such as “organised”, and numeric dates commonly use dd/MM/yyyy. Sydney and Melbourne require IANA zones such as Australia/Sydney and Australia/Melbourne; their displayed offsets can change between AEST and AEDT, so never bake the abbreviation into a static message. For an Australian expense product, labels may also identify AUD and standard-rate GST as 10 per cent. Locale, time zone, and currency are related settings, but they are not interchangeable.
Select, validate and retain a locale
A predictable selection order makes behaviour easier to explain: an explicit user choice, a saved account preference, a supported locale in the URL, the request’s Accept-Language value, and finally the application default. Browsers often send broad preferences, so en alone should not be assumed to mean Australian English. Normalise the request tag and map it to an allowlist before loading resources.
In Grails, configure the locale resolver or interceptor supported by your release and make en-AU the server-side default. A language action can validate a submitted BCP 47 tag, apply it to the current request, and save it for the configured resolver to restore later.
def supportedTags = ['en-AU', 'ar', 'zh-CN']
def language() {
def candidate = Locale.forLanguageTag(params.tag ?: '')
if (!(candidate.toLanguageTag() in supportedTags)) {
response.sendError(400)
return
}
request.setLocale(candidate)
session.setAttribute('app.locale', candidate)
redirect(action: 'index')
}
Use en-AU with a hyphen in URLs and form values; the underscore form is mainly for Java resource filenames. Replace the fixed redirect with a validated internal return route so users keep their place, and reject unknown regions instead of constructing file paths from unchecked input. Store a preference in a scoped cookie or session rather than the authentication record when guests need it too.
The switch itself needs a stable POST endpoint, a clear selected-language state, and an ordinary form control that works without JavaScript. For policy and terminology discussions beyond controller code, teams can consult multilingual content guidance when agreeing how regions, fallback text, and translator notes should be handled. Shared caches also need care: a page rendered for en-AU must not be served to an Arabic or Chinese request, so key private responses by locale or make the cache vary correctly.
Test bundles throughout the application lifecycle
Exercise messages through rendered GSPs, not only by reading properties files. Integration tests should request several supported locales, including en-AU, then assert that headings, labels, error messages, and interpolated values are present. Also request an unsupported locale to confirm the configured default, and temporarily omit a key to verify that fallback reaches the base bundle instead of exposing raw property names.
Test argument counts because a bundle with two placeholders can fail when the view supplies one. Cover zero, one, and many values for count-sensitive screens, check that translated error text remains escaped, and verify keyboard labels and other accessibility attributes. A pseudo-localised bundle can make clipped text, hard-coded English, and bad wrapping easier to spot before professional translation begins.
Bundles are application resources and must be included in the packaged WAR, JAR, or deployment artefact. Keep filenames and directory case correct on Linux hosts, save them in the encoding expected by your Grails runtime, and escape non-ASCII characters if the build still uses an older properties-file convention. Grails and Spring may cache resolved messages; restart the application or use the supported cache refresh path after production changes rather than editing deployed files in place.
Make the bundle part of code review. A change to a visible GSP string should add or update a key, preserve placeholders, and provide context for translators. Locale files should receive the same build checks as Groovy code, while translators retain control over wording and grammar. This keeps framework changes and copy changes independently reviewable and prevents a late translation pass from revealing broken markup.
Create messages.properties, add messages_en_AU.properties, and migrate one complete view today. Test it with a deliberately missing key and a second locale, then make that bundle check part of every pull request. Once the smallest slice is reliable, each new language becomes a data change rather than another round of GSP rewrites.