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.

flowchart TD req["HTTP request"] --> lci{"LocaleChangeInterceptor
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.

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>

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>

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:

Table 1. Methods of #messages
Method Behaviour

msg

Returns the text for a key; a missing key gives the marker such as ??msgKey??

msgOrNull

Same lookup, but returns null for a missing key, so it combines with the Elvis operator

arrayMsg, listMsg, setMsg

Take an array, list or set of keys and return an array, list or set of texts

arrayMsgOrNull, listMsgOrNull, setMsgOrNull

The same, with null entries for missing keys

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, &lt;b onmouseover=&quot;steal()&quot;&gt;Eve&lt;/b&gt;!</p>

<!-- th:utext="#{shelf.welcome(${user})}" -->
<p>Welcome, <b onmouseover="steal()">Eve</b>!</p>

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:

Table 2. spring.messages properties
Property Default Meaning

spring.messages.basename

messages

Comma-separated basenames, resolved from the classpath root unless they carry a package; slashes work (i18n/labels)

spring.messages.encoding

UTF-8

Charset of the bundle files, so accents can be written directly

spring.messages.fallback-to-system-locale

true

When no file matches the requested locale, try the JVM default locale before the base file

spring.messages.use-code-as-default-message

false

Show the key instead of the missing-key marker; recommended for development only

spring.messages.always-use-message-format

false

Apply MessageFormat even to messages that have no arguments

spring.messages.cache-duration

not set

How long loaded bundles are cached; not set means forever

spring.messages.common-messages

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>

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:

Table 3. LocaleResolver implementations
Resolver Where the locale comes from Can the user change it? Typical use

AcceptHeaderLocaleResolver (Boot default)

The Accept-Language header of the request; setSupportedLocales limits the accepted list

No, setLocale throws UnsupportedOperationException

Public sites with no user preference

CookieLocaleResolver

A cookie, falling back to a default locale

Yes, through the LocaleChangeInterceptor

A language switcher that survives sessions

SessionLocaleResolver

A session attribute, falling back to a default locale

Yes, through the LocaleChangeInterceptor

A choice that lasts as long as the session

FixedLocaleResolver

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.

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>

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>
Rendered for en-US
<span>1 August 1965</span>,
<span>$12.50</span>,
<span>12.50</span>
<p>October 8, 2026</p>
<p>8 October 2026</p>
Rendered for es-ES
<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 has de between the parts and the English one does not. #temporals formats java.time objects; 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 yyyy gives 8 octubre 2026 in 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' yyyy

    The apostrophes stay single because this message has no arguments and spring.messages.always-use-message-format is off (see the pitfalls below).

  • #numbers.formatCurrency takes the currency from the locale’s region, so the locale needs a country: es-ES gives 12,50 € and en-US gives $12.50. A bare en or es has no region, and the JDK then uses a generic currency sign.

  • #numbers.formatDecimal(number, minIntegerDigits, decimalDigits) uses the locale’s separators (12.50 or 12,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.

  • #dates formats java.util.Date with 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}">
Rendered for es-ES, then for ar
<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 MessageSource each 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:replace receive 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:utext inlining) 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

Table 4. Internationalization pitfalls
Mistake What happens Do this

Leaving spring.messages.fallback-to-system-locale at true

A visitor with an unsupported language gets the language of the server’s JVM

Set it to false so the base file is the default

A single apostrophe in a message that has arguments

MessageFormat swallows it: Don't panic, {0} printed Dont panic, {0} and the argument was not substituted

Write Don''t panic, {0}; with no arguments one apostrophe is fine unless always-use-message-format is on

spring.messages.always-use-message-format=true with old single-apostrophe texts

Every message goes through MessageFormat and Don't panic printed Dont panic

Double the apostrophes in the bundle, or keep the property off

A number as a message argument

It is formatted with grouping: Year 2,026

Pass a string or use {0,number,#}

th:utext on a message with user data

The argument is inserted unescaped, and markup in it runs

th:text, always, for messages with parameters from users

A bean not called localeResolver

DispatcherServlet never uses it and the Boot default stays

Name the @Bean method localeResolver

LocaleChangeInterceptor with the default Boot resolver

UnsupportedOperationException: Cannot change HTTP Accept-Language header

Define a CookieLocaleResolver or SessionLocaleResolver

Using en and es for formatting

formatCurrency has no region; the currency sign may be generic

Resolve full tags such as en-US, es-ES

#locale.displayName for a language name

Uses the JVM default language: inglés (Estados Unidos) on an English page

#locale.getDisplayName(#locale)

Formatting dates with a fixed pattern

8 octubre 2026 in Spanish

Keep the pattern in the bundle or use #temporals.format(date)

Registering a custom resolver and nothing else

Every Boot message becomes ??key_locale??, since the Spring resolver is no longer added

Register SpringMessageResolver yourself, with explicit orders

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 (spring.messages.encoding defaults to it) and save them that way

Version notes

  • SpringTemplateEngine no longer overwrites custom message resolvers from 3.1.5 (#1068). Code that relied on the old overwrite, and code that registered a resolver without SpringMessageResolver on 3.1.4 or earlier, behaves differently.

  • Thymeleaf 3.1 supports jakarta.*, and the old #request, #response and #session utility 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 MessageSource auto-configuration lives in spring-boot-autoconfigure, the spring.messages.* keys include common-messages in the metadata, and the locale keys are spring.web.locale and spring.web.locale-resolver.

  • The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) introduces messages.properties, locale variants and a LocaleChangeInterceptor with CookieLocaleResolver; the ideas are current, but take property names and package names from this page.