Internationalization
|
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) and Java 21, checked on
2026-10-08, and the examples were compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with
--release 21, MockMvc, English and Spanish bundles); the rendered output shown is the real output of those runs. Message
expressions, the #messages object and the formatting utilities are part of the Standard Dialect and work in Thymeleaf standalone,
with Spring MVC and with Spring WebFlux. What changes is where the texts come from (template-side .properties files
standalone, the Spring MessageSource in Spring) and how the locale is found. The LocaleResolver, LocaleChangeInterceptor and
cookie or session examples are Spring MVC; WebFlux has its own LocaleContextResolver mechanism that this page does not cover.
A site for readers in more than one country has two separate jobs. The first is to find out which language and region the visitor
wants (the locale). The second is to look every visible text up in a bundle for that locale and to format dates, numbers and
currencies the way that locale expects. Thymeleaf only does the second job: it asks its message resolvers for the text of a key in the
locale held by the template context, and the formatting utilities read the same locale. The first job belongs to the web framework.
In Spring that is the LocaleResolver, so most of this page is the cooperation between the two. The running scenario is the
Bookshelf of the Getting Started page, now with a shelf page in English and Spanish.
How a locale reaches a text
Two chains meet in the template. The request chain decides the locale, and the bundle chain turns a key and that locale into text.
has a lang parameter?"} lci -- "yes, valid" --> store["LocaleResolver.setLocale
cookie or session stores it"] lci -- "no" --> res store --> res["LocaleResolver.resolveLocale"] res --> hdr["AcceptHeaderLocaleResolver
Accept-Language header"] res --> ck["CookieLocaleResolver
cookie, else default locale"] res --> ses["SessionLocaleResolver
session attribute, else default"] res --> fix["FixedLocaleResolver
always the same locale"] hdr --> ctx["Locale in the Thymeleaf WebContext
#locale, th:lang"] ck --> ctx ses --> ctx fix --> ctx ctx --> msg["message expression with a key"] msg --> bundle["MessageSource
messages_es_ES, messages_es, messages"] bundle -- "found" --> text["text, MessageFormat applied to the parameters"] bundle -- "not found" --> miss["??key_es_ES?? marker"] ctx --> fmt["#temporals, #numbers, #dates
format with the same locale"]
The sections below follow this picture from the bottom (texts) to the top (resolving the locale), then cover formatting, enums,
the lang and dir attributes and custom resolvers.
Message expressions
A message expression #{...} is a Standard Expression type. It is evaluated while the template is processed, and its value is the
text for a key in the locale of the template context. The usual place is th:text, with the prototype text left in the body so the
template stays a natural template: opened straight from the disk, the browser shows the English placeholder.
<h1 th:text="#{shelf.title}">Bookshelf</h1>
<p th:text="#{shelf.welcome(${user})}">Welcome, reader!</p>
<p th:text="#{shelf.row(${book.title}, ${book.author})}">Dune by Frank Herbert</p>
The key is written without quotes. Parameters follow in parentheses and are separated with commas. They are ordinary expressions
(${...}, literals, other message expressions), and they replace the {0}, {1} placeholders of the text in the bundle. The
Standard Expression Syntax tutorial documents the parameter form under messages, and the
Standard Expression Syntax page puts it next to the other expression types.
The bundle entries behind those three lines, English first, then Spanish:
# messages.properties
shelf.title=My bookshelf
shelf.welcome=Welcome, {0}!
shelf.row={0} by {1}
# messages_es.properties (UTF-8)
shelf.title=Mi estantería
shelf.welcome=¡Bienvenido, {0}!
shelf.row={0}, de {1}
Note that the Spanish row text reorders the placeholders freely. That is the reason to pass the title and the author as separate parameters instead of concatenating three messages: word order differs between languages.
Plurals and the other MessageFormat features
In Spring the texts are formatted by java.text.MessageFormat, so the whole format language is available in the bundle. A
choice format is the classic way to write a plural, and each language has its own rule text:
# messages.properties
shelf.summary={0,choice,0#You have no books|1#You have one book|1<You have {0} books}.
# messages_es.properties
shelf.summary={0,choice,0#No tienes libros|1#Tienes un libro|1<Tienes {0} libros}.
<p th:text="#{shelf.summary(${#lists.size(books)})}">You have 2 books.</p>
Running the same key with 0, 1 and 5 against the MessageSource printed:
CHOICE 0: You have no books. | No tienes libros.
CHOICE 1: You have one book. | Tienes un libro.
CHOICE 5: You have 5 books. | Tienes 5 libros.
Source: Java — MessageFormat
ChoiceFormat is simple and not complete: it cannot express the plural categories of languages such as Polish or Arabic. For those,
a library such as ICU4J is the usual answer. That integration is outside what Thymeleaf offers, and the message resolver extension
point described at the end of the page is where it would plug in.
Dynamic keys and message expressions as arguments
The key does not have to be a literal. Inside the braces a variable expression can supply it, and literal substitution builds it from pieces. Both forms are useful to translate values that live in the data, such as the genre of a book:
<span th:text="#{|genre.${book.genre}|}">Science fiction</span>
<span th:text="#{${'genre.' + books[0].genre.name()}}">Science fiction</span>
Source: Using Thymeleaf — Messages
The first line uses the literal-substitution form with |...| and passes the enum straight in, because its toString() is the
name. The second evaluates a SpEL string concatenation. Both printed Science fiction in English and Ciencia ficción in Spanish.
A message expression can also be an argument, which is how a translated text is passed into a fragment. The panel fragment takes
a heading and shows it unchanged, while the message shelf.panel receives the user name:
<section th:fragment="panel(heading, who)">
<h2 th:text="${heading}">Heading</h2>
<p th:text="#{shelf.panel(${who})}">Panel: someone</p>
</section>
<section th:replace="~{i18n/panel :: panel(heading=#{shelf.title}, who=${user})}"></section>
Source: Using Thymeleaf — Fragments
Rendered with the Spanish locale, the panel heading was Mi estantería and the paragraph Panel: Ana. Fragment parameters are covered in
detail on Fragments.
A key that is built from data you control (an enum name, a status code) is fine. A key built from request input is not: anyone who can pick the key can read any entry of the bundle, including entries that were never meant to be shown, and an unexpected key produces the missing-key marker in the page. Map the input to an allow-list of keys first.
The #messages utility object
The same lookup is available inside a variable expression through #messages, which is useful when a message has to be passed to
another utility method or combined in SpEL:
<p th:text="${#messages.msg('shelf.welcome', user)}">msg</p>
<p th:text="${#messages.msgOrNull('no.such.key')} ?: #{shelf.title}">fallback</p>
The 3.1.5 class has these method families, each taking the key and up to three parameters, or an array of parameters through the
WithParams variants:
| Method | Behaviour |
|---|---|
|
Returns the text for a key; a missing key gives the marker such as |
|
Same lookup, but returns |
|
Take an array, list or set of keys and return an array, list or set of texts |
|
The same, with |
Source: Javadoc: Messages 3.1.5.RELEASE
The utility objects are introduced on Expression Utility Objects.
Messages are text, not markup
th:text escapes its result, parameters included. th:utext does not escape, and a message expression with parameters used in it
inserts the parameter values as raw markup. With a user-supplied name, the two attributes rendered:
<!-- th:text="#{shelf.welcome(${user})}" -->
<p>Welcome, <b onmouseover="steal()">Eve</b>!</p>
<!-- th:utext="#{shelf.welcome(${user})}" -->
<p>Welcome, <b onmouseover="steal()">Eve</b>!</p>
Source: Using Thymeleaf — Unescaped Text
Use th:text for every message that takes user data. Reserve th:utext for messages whose parameters are trusted or which you
have escaped yourself, for example a bundle text that contains a link.
Where the texts come from
Spring Boot and the MessageSource
With Spring, the SpringTemplateEngine builds a SpringMessageResolver around the application’s MessageSource bean, so Thymeleaf
and the rest of the application (validation messages, MessageSource calls in controllers) share one set of bundles. Spring Boot
creates that MessageSource when a bundle named after spring.messages.basename (default messages) exists on the classpath. These
are the properties of Boot 4.1.1, read from its configuration metadata:
| Property | Default | Meaning |
|---|---|---|
|
|
Comma-separated basenames, resolved from the classpath root unless they carry a package; slashes work ( |
|
|
Charset of the bundle files, so accents can be written directly |
|
|
When no file matches the requested locale, try the JVM default locale before the base file |
|
|
Show the key instead of the missing-key marker; recommended for development only |
|
|
Apply MessageFormat even to messages that have no arguments |
|
not set |
How long loaded bundles are cached; not set means forever |
|
not set |
Locale-independent property resources of messages shared by every locale |
The lookup for the locale es_ES tries messages_es_ES.properties, then messages_es.properties, then (if enabled) the file of
the JVM default locale, and finally messages.properties. The scratch application sets the system-locale fallback off:
spring.messages.fallback-to-system-locale=false
This is not a cosmetic choice. The first run of the scratch application, on a laptop whose JVM locale is Spanish, rendered the
Spanish shelf for an English visitor, because there is no messages_en.properties and the default true made the JVM default win
over the base file. A plain ResourceBundleMessageSource reproduced it:
FALLBACK system=true fr -> Mi estantería
FALLBACK system=false fr -> My bookshelf
A server in a Spanish data centre would show every unsupported visitor Spanish. With the property off, the base file is the real default language.
Missing keys
When no resolver finds a key, Thymeleaf renders a marker made of the key and the locale between double question marks:
<p th:text="#{no.such.key}">missing</p>
<p>??no.such.key_en_US??</p>
<p>??no.such.key_es_ES??</p>
Source: Using Thymeleaf — Messages
The marker is deliberately ugly so that a missing translation is seen in a review. It also shows the locale that was used, which
is the fastest way to find out that a visitor is being resolved to es_ES when you expected es. With
spring.messages.use-code-as-default-message=true the page shows the plain key instead:
<p>no.such.key</p>
Be aware that this setting makes the Spring lookup return the key, so msgOrNull no longer returns null and the Elvis fallback
shown earlier is never taken. Keep the property for local development.
Standalone: template-side bundles
Without Spring the default message resolver is org.thymeleaf.messageresolver.StandardMessageResolver. It looks for
.properties files next to the template, named after it: for the template home it reads home_es_ES.properties,
home_es.properties and home.properties. The locale comes from the Context and not from the request, so it must be set:
<p th:text="#{welcome(${name})}">Welcome</p>
<p th:text="#{only.default}">default</p>
<p th:text="#{missing}">missing</p>
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver); // ClassLoaderTemplateResolver, prefix templates/
for (String tag : new String[] {"en", "es", "es-ES", "fr"}) {
Context ctx = new Context(Locale.forLanguageTag(tag));
ctx.setVariable("name", "Ana");
System.out.println(engine.process("i18n/home", ctx));
}
With home.properties holding English texts and home_es.properties the Spanish welcome, the four runs printed:
en: Welcome, Ana! / Only in the default file / ??missing_en??
es: ¡Bienvenido, Ana! / Only in the default file / ??missing_es??
es-ES: ¡Bienvenido, Ana! / Only in the default file / ??missing_es_ES??
fr: Welcome, Ana! / Only in the default file / ??missing_fr??
The tutorial’s own example puts those files in /WEB-INF/templates/, next to the templates. An unsupported language (fr) falls to
the base file, and the engine does not consult the JVM default locale. A context built with no locale falls back to the system
locale, which the tutorial warns against in real applications. The same template-side files are also found when the Spring engine
is used and the key is not in the MessageSource; in the scratch run the Spring-engine rendering of home for Spanish gave the same
three lines as above. The details of standalone configuration are on
Standalone Engine & Configuration.
Resolving the locale
In Spring MVC the DispatcherServlet asks a bean named localeResolver for the locale of each request. Thymeleaf’s view then puts
that locale in the template context, where #locale exposes it and the message and formatting machinery use it. Spring offers four
implementations:
| Resolver | Where the locale comes from | Can the user change it? | Typical use |
|---|---|---|---|
|
The |
No, |
Public sites with no user preference |
|
A cookie, falling back to a default locale |
Yes, through the |
A language switcher that survives sessions |
|
A session attribute, falling back to a default locale |
Yes, through the |
A choice that lasts as long as the session |
|
A locale configured once |
No |
Single-language deployments and tests |
The Boot default and the spring.web properties
Spring Boot registers an AcceptHeaderLocaleResolver unless you define your own bean named localeResolver. Two properties, in
spring.web, adjust it: spring.web.locale is the locale to use, and spring.web.locale-resolver takes accept-header (default)
or fixed. In the scratch application, with those two set to fixed and es-ES, the request below came back in Spanish even though
it asked for English:
spring.web.locale-resolver=fixed
spring.web.locale=es-ES
FIXED resolver: org.springframework.web.servlet.i18n.FixedLocaleResolver
FIXED -> [<html lang="es-ES" dir="ltr">, <h1>Mi estantería</h1>]
With the default accept-header resolver the browser’s language list decides. Sending Accept-Language: es-ES,es;q=0.9,en;q=0.8
rendered the Spanish page and fr-FR,fr;q=0.9 rendered <html lang="fr-FR"> with the English texts, because the bundle has
no French. A request with no header resolved to the container’s default request locale (en under MockMvc). Because a header with a
country (fr-FR) resolves to fr_FR, the lang attribute and the number formats follow it even when the texts do not.
A cookie resolver and the LocaleChangeInterceptor
To let the visitor choose, register a resolver that can store a choice, and an interceptor that reads the choice from a request
parameter. The bean method must be called localeResolver:
@Configuration
public class I18nConfig implements WebMvcConfigurer {
@Bean
LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("bookshelf-lang");
resolver.setDefaultLocale(Locale.US);
resolver.setCookieMaxAge(Duration.ofDays(365));
resolver.setCookieHttpOnly(true);
resolver.setCookieSameSite("Lax");
return resolver;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
interceptor.setIgnoreInvalidLocale(true);
registry.addInterceptor(interceptor);
}
}
CookieLocaleResolver takes the cookie name in its constructor; the 7.0 class has setters for the maximum age (a Duration), path,
domain, Secure, HttpOnly and SameSite. The default locale is the answer for a visitor with no cookie. The interceptor reads the
parameter named by setParamName (default locale), turns the value into a Locale and calls setLocale on the resolver, which
writes the cookie. With setIgnoreInvalidLocale(true) an invalid value is ignored instead of failing the request. A request with
?lang=es-ES returned the Spanish page and this header:
Set-Cookie: bookshelf-lang=es-ES; Path=/; Max-Age=31536000; Expires=Thu, 7 Oct 2027 22:42:10 GMT; HttpOnly; SameSite=Lax
A following request that sent only the cookie rendered <html lang="es-ES"> and the Spanish page, and ?lang=!! changed nothing.
?lang=ar set the cookie to ar and the texts stayed English, because there is no Arabic bundle, while the page switched to
dir="rtl" (see the lang and dir section below).
The same two beans with SessionLocaleResolver (no cookie, the choice lives in the HttpSession) behaved like this over three
requests with one session:
SESSION lang=null -> [<html lang="en-US" dir="ltr">, <h1>My bookshelf</h1>]
SESSION lang=es-ES -> [<html lang="es-ES" dir="ltr">, <h1>Mi estantería</h1>]
SESSION lang=null -> [<html lang="es-ES" dir="ltr">, <h1>Mi estantería</h1>]
Two cautions follow from the runs. The interceptor works with a resolver that can store a locale: with the Boot default, the same
?lang=es-ES request ended in an error, which Spring reported as:
UnsupportedOperationException: Cannot change HTTP Accept-Language header - use a different locale resolution strategy
Second, the language switch is made with a GET request here, which is how the interceptor works by default (its
setHttpMethods method restricts it to chosen HTTP methods). It changes only the visitor’s own language preference and nothing on the
server, so it is safe without a CSRF token, but it must never carry a side effect. Every state change in the application stays a
POST with CSRF protection on.
The language switcher fragment
A switcher is a fragment with one link per language, included in the layout so every page offers it. The link points at the current page with the parameter that the interceptor reads:
<nav th:fragment="switcher">
<a th:href="@{''(lang='en-US')}" hreflang="en" lang="en">English</a> |
<a th:href="@{''(lang='es-ES')}" hreflang="es" lang="es">Español</a>
</nav>
<nav>
<a href="?lang=en-US" hreflang="en" lang="en">English</a> |
<a href="?lang=es-ES" hreflang="es" lang="es">Español</a>
</nav>
Source: Using Thymeleaf — Link URLs
An empty base with a parameter list renders a page-relative URL made only of the query string, so the link stays on the current
path. It also replaces the whole query string, which loses other parameters such as a page number; carry them explicitly where that
matters. Each link names its own language in that language (lang="es" on Español) so a screen reader pronounces it correctly, and
hreflang states the language of the target for search engines and assistive technology. Link expressions are explained on
Link URLs and the layout that would include the switcher is on
Layouts & the Layout Dialect.
Locale-aware formatting
Messages are only half of a localized page. Dates, numbers and money must follow the locale as well, and the utility objects read it from
the same context. This part of the template, rendered for en-US and es-ES, shows what changes:
<span th:text="${#temporals.format(book.published, #messages.msg('date.pattern'))}">1 August 1965</span>,
<span th:text="${#numbers.formatCurrency(book.price)}">$12.50</span>,
<span th:text="${#numbers.formatDecimal(book.price, 1, 2)}">12.50</span>
<p th:text="${#temporals.format(updated)}">updated</p>
<p th:text="${#dates.format(legacyDate, 'd MMMM yyyy')}">legacy date</p>
<span>1 August 1965</span>,
<span>$12.50</span>,
<span>12.50</span>
<p>October 8, 2026</p>
<p>8 October 2026</p>
<span>1 de agosto de 1965</span>,
<span>12,50 €</span>,
<span>12,50</span>
<p>8 de octubre de 2026</p>
<p>8 octubre 2026</p>
Reading the output:
-
#temporals.format(date)with no pattern gives the locale’s default style: the Spanish line hasdebetween the parts and the English one does not.#temporalsformatsjava.timeobjects; the archived "What’s new in 3.1" article records that it moved into the core and can format with a non-default locale. -
A pattern is not translated.
d MMMM yyyygives8 octubre 2026in Spanish, with month names in the right language but a sentence order that is English. The shelf template therefore keeps the pattern in the bundle (date.pattern) and reads it through#messages.msg; the Spanish entry is written with quoted literals:# messages.properties date.pattern=d MMMM yyyy # messages_es.properties date.pattern=d 'de' MMMM 'de' yyyyThe apostrophes stay single because this message has no arguments and
spring.messages.always-use-message-formatis off (see the pitfalls below). -
#numbers.formatCurrencytakes the currency from the locale’s region, so the locale needs a country:es-ESgives12,50 €anden-USgives$12.50. A bareenoreshas no region, and the JDK then uses a generic currency sign. -
#numbers.formatDecimal(number, minIntegerDigits, decimalDigits)uses the locale’s separators (12.50or12,50). The variant with explicit separators,#numbers.formatDecimal(n, 1, 'POINT', 2, 'COMMA'), fixes them regardless of the locale, which is what you want for a machine-readable value and not for a visible price. -
#datesformatsjava.util.Datewith the same locale rules and is kept for legacy types.
The ${{...}} double-brace syntax goes through Spring’s ConversionService with the request locale, and @DateTimeFormat and
@NumberFormat on the model drive form display and binding; that part of localization belongs to
Conversion & Formatting.
Be careful with message parameters too: a MessageFormat placeholder formats numbers. The {0} of Year {0} with the number
2026 printed:
Year 2,026 | Año 2.026 | string: Year 2026
A year, an id or a phone number should be passed as a string (or written {0,number,#} in the bundle).
Translated enums
An enum constant must not be shown with toString() or name(): that text is English, uppercase and code. The convention that worked
above is one bundle key per constant, built from a prefix and the name:
public enum Genre { SCIFI, FANTASY, MYSTERY }
# messages.properties
genre.SCIFI=Science fiction
genre.FANTASY=Fantasy
genre.MYSTERY=Mystery
# messages_es.properties
genre.SCIFI=Ciencia ficción
genre.FANTASY=Fantasía
genre.MYSTERY=Misterio
<span th:text="#{|genre.${book.genre}|}">Science fiction</span>
The same key convention feeds <option> labels in a form: iterate the enum values and put the message in the option text. Adding a
constant then fails visibly as ??genre.NEWONE_en_US?? until the translations are added, which is the behaviour you want from a
checked-by-eye marker. Forms are covered on Forms & Data Binding.
The lang and dir attributes
The browser, screen readers, translation tools and search engines read lang on the <html> element to know the language of the
page, and dir to know the writing direction. Both should follow the resolved locale:
<html lang="en" th:lang="${#locale.toLanguageTag()}" th:dir="${dir}">
<html lang="es-ES" dir="ltr">
<html lang="ar" dir="rtl">
#locale is the basic expression object for the context locale, so #locale.toLanguageTag() yields a BCP 47 tag
(es-ES) which is the format lang expects. th:lang and th:dir are among the HTML attributes the Standard Dialect sets by name. For
pages that also need xml:lang (XHTML), th:lang-xmllang sets both at once
(Text & Attribute Setting lists the attribute families). The static lang="en" stays as the prototype value.
The value for dir is a model attribute because Thymeleaf has no right-to-left switch of its own. The scratch controller derives it
from the language:
private static final Set<String> RTL = Set.of("ar", "fa", "he", "ur");
@GetMapping("/i18n/shelf")
String shelf(Model model, Locale locale) {
model.addAttribute("dir", RTL.contains(locale.getLanguage()) ? "rtl" : "ltr");
// ...
return "i18n/shelf";
}
A @ControllerAdvice with a @ModelAttribute method is the usual home for it, so every page gets dir without repeating the code.
Mark text in another language than the page with its own lang (the switcher does it for the language names), and show a locale’s
name with #locale.getDisplayName(#locale): the one-argument #locale.displayName uses the server’s default language and rendered
inglés (Estados Unidos) in an English page in the scratch run, while the two-argument call gave English (United States) and
español (España).
Custom message resolvers
Message lookup is pluggable. The engine holds a set of org.thymeleaf.messageresolver.IMessageResolver objects, ordered by
getOrder() (lowest first), and asks them one after another until one returns a non-null text. If none does, the same ordered walk
looks for a non-null createAbsentMessageRepresentation; in a plain engine run where two resolvers returned null for both
methods the element came out empty, with no marker. The ??key_locale?? text is what the Spring and Standard resolvers
return as their absent representation. A resolver is a good fit for texts that come from a database, a content system or a feature flag:
public class BranchMessageResolver implements IMessageResolver {
private final Map<String, String> overrides;
public BranchMessageResolver(Map<String, String> overrides) { this.overrides = overrides; }
@Override public String getName() { return "BRANCH"; }
@Override public Integer getOrder() { return 10; } // lower runs first
@Override
public String resolveMessage(ITemplateContext context, Class<?> origin, String key, Object[] messageParameters) {
String pattern = overrides.get(key);
if (pattern == null) {
return null; // not mine: the next resolver is asked
}
return new MessageFormat(pattern, context.getLocale()).format(messageParameters);
}
@Override
public String createAbsentMessageRepresentation(ITemplateContext context, Class<?> origin, String key,
Object[] messageParameters) {
return null; // let the other resolvers decide how a missing message looks
}
}
Standalone, TemplateEngine.addMessageResolver (or setMessageResolver) registers it. With Spring there is a trap that the 3.1
line changed. Before 3.1.5, SpringTemplateEngine replaced a custom resolver with its own; the 3.1.5 release notes list "no longer
overwrites custom MessageResolver`s" (#1068). The fix respects what you registered, and the consequence is that once you register
any resolver the engine does not add the Spring one for you. The decompiled `initializeSpecific of 3.1.5 only installs a
SpringMessageResolver when no resolver was set by the user. In the scratch run, adding only BranchMessageResolver made every
Boot message render as ??shelf.title_en_US??. The configuration that works registers both, with explicit orders:
@Configuration
public class MessageResolverConfig {
@Autowired
void addMessageResolvers(SpringTemplateEngine engine, MessageSource messageSource) {
// Once any resolver is registered, Thymeleaf no longer installs the Spring one by itself.
SpringMessageResolver spring = new SpringMessageResolver();
spring.setMessageSource(messageSource);
spring.setOrder(100);
engine.addMessageResolver(spring);
engine.addMessageResolver(new BranchMessageResolver(Map.of("shelf.branch", "Open until {0}, downtown branch")));
}
}
<p th:text="#{shelf.branch(${updated})}">branch</p>
The page printed Open until 2026-10-08, downtown branch from the custom resolver, while the other keys still came from the
bundles. The listing of the engine’s resolvers showed the three in order: the branch resolver (10), the Spring one (100) and the
engine’s default StandardMessageResolver, which has no order. The method runs during bean setup, before the first
template is processed, because a template engine must be configured before its first use. SpringTemplateEngine can also use a different source
for templates than for the rest of the application with setTemplateEngineMessageSource. The same custom-resolver support came
with 3.1, for non-Spring engines too, as the archived "What’s new in 3.1" article lists.
Where it runs
-
Message expressions and the utility objects run while the template is processed, on every render. The parsed expression is cached, but the lookup is not: it goes to the
MessageSourceeach time, which caches the bundles. -
The prototype text in the tag body stays in the file, so the template remains natural; the browser shows it when the file is opened directly. Fragments and
th:replacereceive messages as arguments, as shown. -
Message keys and parameters are ordinary expressions. They are evaluated under the same rules as other attributes, and the restricted-mode contexts of the tutorial’s Appendix D (event handler attributes, preprocessing,
th:utextinlining) apply to them as to any expression. Do not build keys in a restricted context from request data. -
Inlining works too:
[[#{shelf.title}]]in a text node or in a script is a message expression, see Inlining.
Pitfalls
| Mistake | What happens | Do this |
|---|---|---|
Leaving |
A visitor with an unsupported language gets the language of the server’s JVM |
Set it to |
A single apostrophe in a message that has arguments |
|
Write |
|
Every message goes through MessageFormat and |
Double the apostrophes in the bundle, or keep the property off |
A number as a message argument |
It is formatted with grouping: |
Pass a string or use |
|
The argument is inserted unescaped, and markup in it runs |
|
A bean not called |
|
Name the |
|
|
Define a |
Using |
|
Resolve full tags such as |
|
Uses the JVM default language: |
|
Formatting dates with a fixed pattern |
|
Keep the pattern in the bundle or use |
Registering a custom resolver and nothing else |
Every Boot message becomes |
Register |
Building a key from request input |
Visitors can read any bundle entry, or get the missing-key marker |
Map the input to an allow-list of keys |
Non-ASCII text in a bundle read as ISO-8859-1 |
Accented letters show as garbage in the page |
Keep the files in UTF-8 ( |
Version notes
-
SpringTemplateEngineno longer overwrites custom message resolvers from 3.1.5 (#1068). Code that relied on the old overwrite, and code that registered a resolver withoutSpringMessageResolveron 3.1.4 or earlier, behaves differently. -
Thymeleaf 3.1 supports
jakarta.*, and the old#request,#responseand#sessionutility objects were removed. That matters here because the locale is read from#locale, and the current path for a switcher comes from the link expression, not from the request object. The archived article is Thymeleaf 3.1: What’s new (archived). -
In Spring Boot 4.1.1 the
MessageSourceauto-configuration lives inspring-boot-autoconfigure, thespring.messages.*keys includecommon-messagesin the metadata, and the locale keys arespring.web.localeandspring.web.locale-resolver. -
The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) introduces
messages.properties, locale variants and aLocaleChangeInterceptorwithCookieLocaleResolver; the ideas are current, but take property names and package names from this page.