Localization and i18n
|
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 Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21,
the combination that every example was built, deployed and requested on; the facts were checked on 2026-10-08 and
2026-10-10 against the Core Developer Guide pages on https://struts.apache.org/core-developers/, the Tag Developer Guide,
the 7.4.0 struts2-core jar (default.properties, struts-default.xml, javap of TextProvider and ActionSupport)
and the 7.4.0 source of StrutsLocalizedTextProvider. Struts has one mechanism for every piece of text that is not
hard-coded in a page: property files that live next to the Java classes, a lookup that walks the class hierarchy, the
model, the package tree and the global bundles, and a locale that the i18n interceptor puts into the action context
on each request. The same machinery produces form labels, validation and type conversion messages, the output of
s:text and, through MessageFormat, the localized dates and numbers of the views. The running scenario is the
Bookshelf, a catalogue of books exposed as Struts actions; this page uses it to print the seven levels of the bundle
search, to switch the page between English, German, Spanish and French with request_locale, to format a price and a
publication date for each locale and to measure what happens with a locale that nobody supports. The neighbouring pages are linked where they help: value stack and OGNL, type conversion, validation and the form tags, besides the chapters on actions, interceptors and configuration.
Where Struts Needs Localized Text
The Core Developer Guide lists three places where the framework internationalizes: the UI tags, the messages and errors
of the ValidationAware interface (field errors, action errors, action messages), and the action classes that extend
ActionSupport, through getText(). All three use the same lookup, LocalizedTextProvider.findText(...), and the same
locale, the one stored in the ActionContext. A page therefore never asks for a locale; it asks the action (or the
s:text tag) for a key and receives the text for whichever locale the request was given.
| Place | How the text is asked for | Example key |
|---|---|---|
JSP or FreeMarker output |
|
|
Form labels |
The |
|
Validation and conversion messages |
|
|
Dates in tags |
|
|
Framework messages |
The default bundles |
|
Action code |
Any |
|
The Bookshelf Used on This Page
All examples below are classes and mappings of one WAR, g9i18n.war, deployed under the context path /g9i18n on Tomcat
11.0.26. It is the Struts Bookshelf of the Getting Started page of this section (the Struts BOM struts2-bom 7.4.0,
struts2-core, Servlet API 6.1 and JSP API 4.0 as provided, one StrutsPrepareAndExecuteFilter mapped to /*, no
struts.properties; see Getting Started with Struts 2) with a set of
small actions whose only purpose is to make the bundle lookup visible. Every "Observed" block below is the literal
response of a request to that WAR; the JVM default locale was en_US.
<struts>
<constant name="struts.custom.i18n.resources" value="global"/>
<package name="default" extends="struts-default" namespace="/">
<action name="order" class="org.example.bookshelf.ShelfAction">
<result>/WEB-INF/views/order.jsp</result>
</action>
<action name="texts" class="org.example.bookshelf.TextsAction" method="lookup">
<result>/WEB-INF/views/texts.jsp</result>
</action>
<action name="locale" class="org.example.bookshelf.LocaleAction">
<result>/WEB-INF/views/locale.jsp</result>
</action>
<action name="price" class="org.example.bookshelf.PriceAction">
<result>/WEB-INF/views/price.jsp</result>
<result name="input">/WEB-INF/views/price.jsp</result>
</action>
</package>
</struts>
The JSPs begin with <%@ taglib prefix="s" uri="/struts-tags" %> and print their results inside a <pre> element so
that the lines can be compared with the bundles. The classes (ShelfAction, BaseBookAction, Book, Titled,
TextsAction, LocaleAction, PriceAction) are introduced where they are needed.
Resource Bundles in Two Minutes
A resource bundle is a family of Java ResourceBundle files that share a base name. In Struts they are plain
.properties files on the class path (src/main/resources in a Maven project), one key=value per line:
# LocaleAction.properties (the base bundle, used when no locale file matches)
book.title=Books
accent=café
format.money={0,number,currency}
format.date={0,date,MM/dd/yyyy}
# LocaleAction_de.properties
book.title=Bücher
accent=Käse über
format.money={0,number,#,##0.00} €
format.date={0,date,dd.MM.yyyy}
Struts does not read the files itself; it asks java.util.ResourceBundle.getBundle(name, locale, classLoader) for the
bundle, so the rules of the JDK apply:
-
The file name is the base name, an underscore and the locale suffix:
LocaleAction_de.properties(language),LocaleAction_de_AT.properties(language and country). For a request inde_ATthe JDK tries_de_AT,_deand the base file, in that order, and each file only needs the keys that differ from the more general one. -
If no file exists for the requested language, the JDK falls back to the JVM default locale before the base file. On the test machine (
en_US) a request forzztherefore ends inLocaleAction.properties; on a server whose default locale is German it would end inLocaleAction_de.properties. Setting-Duser.languageon the server, or shipping a base file in the application’s main language, makes the result predictable. -
Since Java 9 a
.propertiesbundle is read as UTF-8 and, only if the bytes are not valid UTF-8, again as ISO-8859-1. The Bookshelf shows both:LocaleAction_de.propertiesis saved as UTF-8 (Bücher) andLocaleAction_fr.propertiesas ISO-8859-1 (the byte0xE9foré), and both printed correctly on Java 21. The books of the section tell the reader to writeéescapes or to runnative2ascii; neither is needed any more, but mixing encodings in one project is an invitation to a file that is silently read as Latin-1, so keep every file in UTF-8. -
A property file that is not found is not an error: Struts caches the miss and moves on to the next level.
The Resource Bundle Search Order
The default text provider, StrutsLocalizedTextProvider, answers getText("some.key") by trying the following places
in this order and returning the first hit. The Core Developer Guide lists seven steps; the code of 7.4.0 implements
them in the same sequence:
The same seven steps as a table, with the file names of the Bookshelf:
| Step | Where | Detail |
|---|---|---|
1 |
|
The properties file with the simple class name of the action, in the same package; |
2 |
|
Every interface and sub-interface of the action; the Bookshelf’s |
3 |
|
Each superclass in turn, "all the way to |
4 |
The model, when the action implements |
Steps 1 to 3 are repeated for the class of the object returned by |
5 |
|
The one in the package of the class, then the one of each parent package up to the root of the class path; |
6 |
The key hierarchy |
A dotted key is split at the first dot; the head is a property of the action, its type is searched for the tail (see below) |
7 |
Global bundles |
|
Seeing All Seven Levels
The scratch app puts the same keys, lvl.1 to lvl.7, into seven files and removes one key per level, so that the
value printed for each key names the file where the lookup stopped. ShelfAction extends BaseBookAction, implements
the marker interface Titled and ModelDriven<Book>:
public class BaseBookAction extends ActionSupport {
}
public interface Titled {
}
public class ShelfAction extends BaseBookAction implements Titled, ModelDriven<Book> {
private final Book book = new Book();
@Override
public Book getModel() {
return book;
}
}
# org/example/bookshelf/ShelfAction.properties (level 1)
lvl.1=level 1: ShelfAction.properties
key.hier=found at ShelfAction level
# org/example/bookshelf/Titled.properties (level 2)
lvl.1=level 2: Titled.properties
lvl.2=level 2: Titled.properties
ib=from the interface Titled.properties
# org/example/bookshelf/BaseBookAction.properties (level 3)
lvl.1=level 3: BaseBookAction.properties
lvl.2=level 3: BaseBookAction.properties
lvl.3=level 3: BaseBookAction.properties
ib=from the base class BaseBookAction.properties
mb=from the base class BaseBookAction.properties
# org/example/bookshelf/Book.properties (level 4, the model)
lvl.4=level 4: Book.properties (the model)
mb=from the model Book.properties
# org/example/bookshelf/package.properties (level 5)
lvl.5=level 5: org/example/bookshelf/package.properties
pk=nearest package.properties (org/example/bookshelf)
# org/example/package.properties (level 6, the parent package)
lvl.6=level 6: org/example/package.properties
pk=parent org/example/package.properties
pk.parent=only in org/example/package.properties
# global.properties (level 7, struts.custom.i18n.resources)
lvl.7=level 7: global.properties
key.hier=found at global level
The files of the real project repeat lvl.1 to lvl.n so that each level also defines the keys of the deeper ones; the
excerpt above lists the lines that matter. order.jsp prints every key with s:text:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<pre>
lvl.1 = <s:text name="lvl.1"/>
lvl.2 = <s:text name="lvl.2"/>
...
ib = <s:text name="ib"/>
mb = <s:text name="mb"/>
pk = <s:text name="pk"/>
pk.parent = <s:text name="pk.parent"/>
key.hier.sub.deep = <s:property value="getText('key.hier.sub.deep')"/>
key.hier (global fallback) = <s:text name="key.hier"/>
locale = <s:property value="locale"/>
</pre>
Observed (GET /g9i18n/order):
lvl.1 = level 1: ShelfAction.properties
lvl.2 = level 2: Titled.properties
lvl.3 = level 3: BaseBookAction.properties
lvl.4 = level 4: Book.properties (the model)
lvl.5 = level 5: org/example/bookshelf/package.properties
lvl.6 = level 6: org/example/package.properties
lvl.7 = level 7: global.properties
ib = from the interface Titled.properties
mb = from the base class BaseBookAction.properties
pk = nearest package.properties (org/example/bookshelf)
pk.parent = only in org/example/package.properties
key.hier.sub.deep = key.hier.sub.deep
key.hier (global fallback) = found at ShelfAction level
locale = en_US
Four details in that output are worth a second look:
-
ibexists in the interface file and in the base-class file and the interface wins: the order is action, interfaces, base classes, which is not the order of the Java type hierarchy. -
mbexists inBaseBookAction.propertiesand in the model’sBook.properties, and the base class wins, because the model is consulted only after the whole class hierarchy of the action. -
pkshows that the nearestpackage.propertieshides the one of the parent package, whilepk.parentshows that a key missing from the nearest file is still found higher up the package tree. -
key.hieris defined inShelfAction.propertiesand inglobal.properties; the global file is the last resort, not a place that can override a more specific bundle. This is the behaviour to rely on when a project keeps common texts in a global bundle and lets a single action give a different wording.
The Package Tree
Step 5 walks up the directory structure of the class. The Core Developer Guide draws it with com/acme/package.properties
and com/acme/actions/package.properties: if FooAction.properties does not define the key, com/acme/actions/ is
searched, then com/acme/, then com/, and so on up to the root of the class path. Placing the labels that several
actions share (the field names of a form that is submitted to a create and to an update action) in the package file
removes the duplicates that the "one bundle per action" rule produces; the message-resource tutorial of the Getting
Started guide does exactly that with a greeting key in org/apache/struts/package.properties.
The Key Hierarchy (Step 6)
The Core Developer Guide calls step 6 "search up the i18n message key hierarchy itself" and does not explain it. The code
does this: when a dotted key such as book.heading was not found, Struts treats the part before the first dot as a
property of the action (getBook()), takes the property’s type, and runs the whole lookup again for the rest of the
key (heading) with that type as the starting class. The Bookshelf’s BookAction has a Book getBook() property, and
Book.properties has the key heading:
public class BookAction extends ActionSupport {
private final Book book = new Book();
public Book getBook() {
return book;
}
}
# org/example/bookshelf/Book.properties
heading=Heading found in Book.properties through the property book
book.heading = <s:text name="book.heading"/>
getText = <s:property value="getText('book.heading')"/>
Observed (GET /g9i18n/child): both lines print Heading found in Book.properties through the property book. In the
action plain, which has no book property, the same two lines print the key book.heading. This is why the
key attribute of a form field named person.firstName can be labelled from Person.properties, the class of the
nested bean, with no label entry in the action’s own file; it is also why the getText of an unknown dotted key costs
a reflective look at the first segment. The order page above has a related line: getText('key.hier.sub.deep') printed the key itself although
key.hier exists, because only the first segment is peeled off and the tail is then looked up in the type of that
property; a longer key is not shortened from the right.
Actions Without a Class
An <action> with no class attribute (a pure forward, <action name="plain"> in the Bookshelf) is instantiated from
the package’s default class reference. In 7.4.0 the default-class-ref of struts-default.xml is
org.apache.struts2.ActionSupport, so for such an action step 3 reaches ActionSupport.properties, which the Core
Developer Guide puts in org/apache/struts2/. The scratch app has org/apache/struts2/ActionSupport.properties with
one key, common.save, and both the class-less plain action and BookAction print it:
common.save = <s:text name="common.save"/>
...
<s:submit key="common.save"/>
Observed (GET /g9i18n/plain): common.save = Save (from org/apache/struts2/ActionSupport.properties). The same
trick is the Struts 7 replacement for the single application.properties of Struts 1: one ActionSupport.properties
holds the texts that every action of the application shares (button captions, "required" markers), as long as the
actions extend ActionSupport. Putting a package-wide file at the top of the package tree, or a global bundle, is the
alternative that does not depend on the base class.
Global Bundles and the Framework’s Own Texts
A global bundle is one that is searched last, for every action, regardless of the class or package. It is declared with
the constant struts.custom.i18n.resources, a comma separated list of base names (not file names, no extension, the
package written with dots or slashes):
<constant name="struts.custom.i18n.resources" value="global"/>
<!-- several bundles: -->
<constant name="struts.custom.i18n.resources" value="global,org.example.bookshelf.errors"/>
The same constant can be written in struts.properties (struts.custom.i18n.resources=global). Bundles are looked up
with the request locale, so global_de.properties next to global.properties works exactly as for any other bundle.
The Bookshelf’s global.properties carries shop.name=Bookshelf, the contact text and key.hier, the key that
the order table above showed being overridden by ShelfAction.properties.
The framework registers two bundles of its own after the custom ones: org/apache/struts2/struts-messages (token,
upload and development-mode messages, struts.messages.*, with translations for da, de, en, pl and pt) and
org/apache/struts2/xwork-messages (xwork.default.invalid.fieldvalue=Invalid field value for field "{0}"., the text
of every conversion error that nothing more specific overrides, and the "no action mapped" messages). A key of the same
name in your global bundle comes first, which is how an application replaces
struts.messages.invalid.token or xwork.default.invalid.fieldvalue with its own wording.
Three constants change how the search itself behaves:
| Constant | Default | Effect |
|---|---|---|
|
|
Since 2.6 the global bundles can be searched before the class hierarchy. With |
|
the hierarchy provider ( |
|
|
|
Development-time reloading of bundles; the sample |
Reading Messages: getText, s:text, s:i18n and key
Struts offers three ways to put a message on a page, and the localization page lists them: getText called from a tag,
the text tag and the i18n tag; the UI tags add the key attribute. They differ in what happens when the key is
missing and in which bundle they use, so the Bookshelf’s TextsAction prints all of them side by side:
public class TextsAction extends ActionSupport {
public String getReader() { return "Ana"; }
public int getCount() { return 1234; }
public String getTitle() { return "Dune"; }
public String lookup() { return SUCCESS; }
public String greetingFromJava() {
return getText("greet", new String[] {getReader(), String.valueOf(getCount())});
}
public String missing() { return getText("no.such.key"); }
public String missingWithDefault() { return getText("no.such.key", "fallback text"); }
}
# org/example/bookshelf/TextsAction.properties
hello=Welcome to the Bookshelf
greet=Hello {0}, you have {1} books
count.only=Books: {0,number,integer}
quote=It's a book
quote.args=It''s book number {0}
quote.args.bad=It's book number {0}
title.line=Title: %{title}
reader.line=Reader: ${reader}
html=<b>bold</b> & "quoted"
s:text hello = <s:text name="hello"/>
getText hello = <s:property value="getText('hello')"/>
s:text with params = <s:text name="greet"><s:param value="reader"/><s:param value="count"/></s:text>
getText with list = <s:property value="getText('greet', {reader, count})"/>
getText with array = <s:property value="greetingFromJava()"/>
number integer = <s:text name="count.only"><s:param value="count"/></s:text>
quote (no args) = <s:text name="quote"/>
quote.args = <s:text name="quote.args"><s:param value="42"/></s:text>
quote.args.bad = <s:text name="quote.args.bad"><s:param value="42"/></s:text>
OGNL in a message = <s:text name="title.line"/>
EL in a message = <s:text name="reader.line"/>
html in a message = <s:text name="html"/>
html escaped = <s:text name="html" escapeHtml="true"/>
missing, s:text = [<s:text name="no.such.key"/>]
missing, getText = [<s:property value="getText('no.such.key')"/>]
missing, default arg = [<s:property value="missingWithDefault()"/>]
missing, tag body = [<s:text name="no.such.key">default from the tag body</s:text>]
missing, hasKey = <s:property value="hasKey('no.such.key')"/> / <s:property value="hasKey('hello')"/>
var = <s:text name="hello" var="h"/>[<s:property value="#h"/>]
i18n tag, other bundle = <s:i18n name="org.example.bookshelf.Titled"><s:text name="ib"/></s:i18n>
i18n tag + property = <s:i18n name="org.example.bookshelf.Titled"><s:property value="getText('ib')"/> | <s:property value="text('ib')"/></s:i18n>
Observed (GET /g9i18n/texts):
s:text hello = Welcome to the Bookshelf
getText hello = Welcome to the Bookshelf
s:text with params = Hello Ana, you have 1,234 books
getText with list = Hello Ana, you have 1,234 books
getText with array = Hello Ana, you have 1234 books
number integer = Books: 1,234
quote (no args) = Its a book
quote.args = It's book number 42
quote.args.bad = Its book number {0}
OGNL in a message = Title: Dune
EL in a message = Reader: Ana
html in a message = <b>bold</b> & "quoted"
html escaped = <b>bold</b> & "quoted"
missing, s:text = [no.such.key]
missing, getText = [no.such.key]
missing, default arg = [fallback text]
missing, tag body = [default from the tag body]
missing, hasKey = false / true
var = [Welcome to the Bookshelf]
i18n tag, other bundle = from the interface Titled.properties
i18n tag + property = ib |
What the output proves, line by line:
-
s:textandgetTextgive the same text;s:textis the idiom inside a page andgetTextis the only way to fill a tag attribute (<s:textfield label="%{getText('person.lastName')}"/>), because a tag cannot be nested inside a tag attribute. The tag reference says exactly this. -
The positional parameters
{0},{1}are filled from thes:paramchildren, from the list of the OGNL expression{reader, count}or from the Java array. ANumberargument is formatted for the request locale, so the integer 1234 prints1,234; the same value passed as aString(the Java example,String.valueOf(getCount())) prints1234because a string is not a number toMessageFormat. -
quoteandquote.args.badlose their apostrophe: the text is run throughjava.text.MessageFormat, where a single quote starts a quoted section.It's a bookprintedIts a bookeven with no arguments, andIt's book number {0}printedIts book number {0}because everything after the quote is literal, including the placeholder. The message must be writtenIt''s book number {0}(two single quotes), which is the single most common i18n bug of a new Struts project and is the reason thes:datepattern of the Spanish bundle below needs doubled quotes. -
%{title}inside a message is evaluated as OGNL against the value stack (Title: Dune), and the${reader}of a JSP EL expression is evaluated too (Reader: Ana). The default text provider evaluates a message after reading it from the bundle, which makes a bundle a place where an expression can run. Text that comes from users must never be put in a bundle or passed through a mechanism that evaluates it (see the security section below and the bulletins that involve forced evaluation). -
A message is not HTML-escaped:
<b>bold</b>was written to the page as markup. Thes:texttag has the attributesescapeHtml,escapeXml,escapeJavaScriptandescapeCsv(all false by default);escapeHtml="true"printed the entities. Escaping depends on where the text is used, so apply it in the view and leave the bundle plain. -
A missing key never throws.
s:textandgetText(key)both print the key itself ([no.such.key]), which is how an untranslated screen looks in production;getText(key, defaultValue)and the body ofs:textsupply a default that is used instead;hasKey(key)answersfalsewithout side effects, so a template can choose between a translation and a fallback. -
var="h"stores the text in the action scope of the value stack and prints nothing, as the tag reference says. -
s:i18n name="org.example.bookshelf.Titled"pushes another bundle on the value stack, and thes:textinside it readibfromTitled.properties(the interface file, because the bundle named by the tag is searched first). The last line is a trap:getText('ib')insides:i18nstill asks the action and printed the key, whiletext('ib')returned nothing; the bundle named by the tag was reachable only throughs:text. The tag reference’s example usestext('aaa.bbb.ccc')inside the tag; on 7.4.0 withs:propertythat call printed an empty string.
Source: Tag Developer Guide — text tag
Source: Tag Developer Guide — i18n tag
Source: Struts Javadoc — TextProvider
The getText Overloads
ActionSupport implements TextProvider; javap of the 7.4.0 jar lists the overloads and the two bundle methods. In
7.x the interface lives in org.apache.struts2.text, not in the old com.opensymphony.xwork2 package that older pages
and books name.
| Method | Use |
|---|---|
|
|
|
The message, or the key when nothing is found |
|
The message, or the default; the default is also run through the formatter |
|
One argument, for example a field name |
|
Positional parameters; a |
|
Both |
|
The same lookup against another value stack, used by tags and by code that runs outside an action |
|
The |
|
Formats the value of an OGNL expression with a message format; see the formatting section |
|
The locale of the request, from |
Source: Struts Javadoc — ActionSupport
The key Attribute of the UI Tags
<s:textfield key="price"/> expands to the same field with name="price" and a label that is the text for the key
price, so the label, the field name and the validation message agree. The message-resource tutorial of Getting
Started shows it for personBean.firstName (label from Register.properties, field name from the key). On a page that
is rendered after an action of the Bookshelf, PriceAction.properties supplies price=Price and
PriceAction_de.properties supplies price=Preis; the child action child of the earlier example renders the form
without a bundle that has price and the label printed the key: <label ...>price:</label>, the same "key as the
fallback text" rule as s:text. The consequence: a label that shows a bare key name means the bundle of this action
does not reach the key, and a JSP that is rendered directly (not through an action) never gets a bundle at all.
The i18n Interceptor and the Request Locale
The bundles are chosen by locale, and the locale of the request is decided by the i18n interceptor, which is in the
defaultStack of Struts 7.4.0 (fifth after exception, alias, servletConfig and httpMethod; see
Interceptors). The interceptor puts a Locale into the ActionContext
on every request; the UI tags, the validators, the conversion error messages and getText all read it from there.
The order of its decisions, in the words of the Core Developer Guide: a request parameter that names a locale wins and
is remembered; otherwise the stored locale is used; if there is none the browser’s locale is used. On top of that,
the interceptor removes its own parameters so that nothing is ever set on the action.
| Parameter | Default | Meaning |
|---|---|---|
|
|
Request parameter that switches the locale and stores it (in the session by default) |
|
|
Request parameter that switches the locale and stores it in a cookie |
|
|
Request parameter that switches the locale for this request only |
|
|
Session key (and cookie name) of the stored locale |
|
|
Where the locale is stored: |
|
none |
Comma separated list of the locales the application supports; matched against |
Experiment: the Default Stack and request_locale
LocaleAction has one base bundle (LocaleAction.properties, English) and the language files _de, _es and _fr;
the action itself only publishes a price and a date:
public class LocaleAction extends ActionSupport {
public Date getPublished() { return new Book().getPublished(); } // 1965-08-01
public Double getPrice() { return 1234.5; }
}
locale = <s:property value="locale"/>
session key = <s:property value="#session['WW_TRANS_I18N_LOCALE']"/>
title = <s:text name="book.title"/>
accent = <s:text name="accent"/>
price = <s:text name="format.money"><s:param value="price"/></s:text>
date (s:text) = <s:text name="format.date"><s:param value="published"/></s:text>
date (s:date) = <s:date name="published"/>
Requests to the locale action, which uses the defaultStack and therefore the interceptor with its defaults
(curl with a cookie jar so that the session survives):
| Request | Result |
|---|---|
No parameter, no header |
|
|
|
|
|
|
|
|
|
|
The interceptor refuses it: |
|
|
Header |
|
Header |
|
The Content-Language response header did not follow the interceptor’s choice: after ?request_locale=es and in a
session that already held de the header of the locale action still said en-US. The header must not be used to find
out which locale Struts chose; the getLocale() of the action or the session key is the source of truth.
Storage Modes
Four localeStorage values give four lifetimes. The Bookshelf registers one interceptor instance per mode (the
interceptor element gives the same class several names) and builds an action for each:
<interceptors>
<interceptor name="i18nCookie" class="org.apache.struts2.interceptor.I18nInterceptor"/>
<interceptor name="i18nAccept" class="org.apache.struts2.interceptor.I18nInterceptor"/>
<interceptor name="i18nRequest" class="org.apache.struts2.interceptor.I18nInterceptor"/>
</interceptors>
<action name="locale-cookie" class="org.example.bookshelf.LocaleAction">
<interceptor-ref name="i18nCookie">
<param name="localeStorage">cookie</param>
</interceptor-ref>
<interceptor-ref name="basicStack"/>
<result>/WEB-INF/views/locale.jsp</result>
</action>
<action name="locale-accept" class="org.example.bookshelf.LocaleAction">
<interceptor-ref name="i18nAccept">
<param name="localeStorage">accept_language</param>
<param name="supportedLocale">es,de</param>
</interceptor-ref>
<interceptor-ref name="basicStack"/>
<result>/WEB-INF/views/locale.jsp</result>
</action>
<action name="locale-request" class="org.example.bookshelf.LocaleAction">
<interceptor-ref name="i18nRequest">
<param name="localeStorage">request</param>
</interceptor-ref>
<interceptor-ref name="basicStack"/>
<result>/WEB-INF/views/locale.jsp</result>
</action>
Observed on Tomcat 11.0.26:
localeStorage |
What was observed | When to choose it |
|---|---|---|
|
|
Logged-in applications and anything that already has a session |
|
|
Public sites without a session; a choice that should survive a browser restart |
|
|
One-off rendering (printing a page in another language, an e-mail preview) |
|
|
Pure browser negotiation, no switch at all; |
The mode decides which request parameter the interceptor listens to: the cookie mode needs
request_cookie_locale, the request mode needs request_only_locale, only the session mode listens to
request_locale, and the accept_language mode ignored request_locale in the experiment. A language selector (a few links or a select)
must therefore use the parameter name that belongs to the configured storage, which the Core Developer Guide does not
state in one place.
The supportedLocale list is documented as the filter for Accept-Language: with the value es,de the Bookshelf’s
locale-accept action printed de for de-DE,de;q=0.9, and fr (not in the list) still printed fr, so a locale
outside the list was not replaced by a default on 7.4.0. The documentation only says the interceptor "will try to
match" the supported locales. An application that must restrict the choice has to do it itself, for example with the
constants of the section on caches below or a small custom interceptor placed after i18n.
A Language Selector
The usual selector is a set of links that carry the right parameter. With the default session storage:
<s:url var="en" action="selector"><s:param name="request_locale" value="'en'"/></s:url>
<s:url var="de" action="selector"><s:param name="request_locale" value="'de'"/></s:url>
<p><s:text name="book.title"/>: <a href="${en}">English</a> | <a href="${de}">Deutsch</a></p>
<p><s:a href="%{de}">Deutsch (s:a)</s:a></p>
Observed (GET /g9i18n/selector, then ?request_locale=de with the same cookie jar): the first response printed
Books: with two links of the form /g9i18n/selector.action;jsessionid=...?request_locale=de, the second printed
Bücher: and the links without jsessionid (the session cookie was already accepted). ${de} in the JSP EL resolves a
variable that s:url var stored, and s:a href="%{de}" accepts the same variable. The links are ordinary GET requests,
so no form and no extra mapping is required; for a selector that appears on every page it is customary to put the links
in a shared layout (a JSP tag file or a decorator, see Tag Files & Layouts). The parameter is consumed by the interceptor and never reaches the action: this is the rule that makes
request_locale safe from the params interceptor (and the reason a property of the action called request_locale
would never be set).
A Fixed Locale and the Locale of the Dispatcher
When no interceptor decides, the locale comes from the Dispatcher: the constant struts.locale (for example
en_US) if set, otherwise the locale of the request (Accept-Language). The i18n interceptor of the default stack,
in session mode with nothing stored, leaves that locale in place. The Bookshelf was rebuilt as g9i18nfix.war with
<constant name="struts.locale" value="de"/>:
| Request | Locale |
|---|---|
Default stack, header |
|
Default stack, |
|
|
|
So a fixed struts.locale protects the dispatcher path; it does not stop an interceptor in accept_language mode or
a request_locale from picking a locale, which is why the next section matters.
Formatting Dates and Numbers
Struts has no formatter of its own. A localized number or date is a message in a bundle whose value is a
java.text.MessageFormat pattern, and the locale selects which bundle (and which MessageFormat locale rules) is
used. The Core Developer Guide gives the recipe and the Bookshelf follows it:
# LocaleAction.properties (English, and the fallback)
format.money={0,number,currency}
format.date={0,date,MM/dd/yyyy}
struts.date.format=MMM d, yyyy
# LocaleAction_de.properties
format.money={0,number,#,##0.00} €
format.date={0,date,dd.MM.yyyy}
struts.date.format=dd.MM.yyyy
# LocaleAction_es.properties
format.money={0,number,#,##0.00} €
format.date={0,date,dd/MM/yyyy}
struts.date.format=d ''de'' MMMM ''de'' yyyy
price = <s:text name="format.money"><s:param value="price"/></s:text>
date (s:text) = <s:text name="format.date"><s:param value="published"/></s:text>
date (s:date) = <s:date name="published"/>
date (format) = <s:date name="published" format="yyyy-MM-dd"/>
Observed, with the amount 1234.5 and the date 1965-08-01 (GET /g9i18n/locale?request_locale=...):
| Locale | price (format.money) |
s:text format.date |
s:date (struts.date.format) |
s:date format="yyyy-MM-dd" |
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Source: Tag Developer Guide — date tag
Points to take from the table:
-
{0,number,currency}uses the currency of the locale’s country.frhas no country, so the JDK prints the generic currency sign¤; usefr_FR(or a German-style fixed pattern with a literal€, as the Bookshelf does fordeandes) when a currency symbol is wanted. A language-only locale is enough for texts and wrong for money. -
The
s:datetag readsstruts.date.formatfrom the bundle of the current locale.LocaleAction_fr.propertiesdoes not define it, so the lookup fell back to the base file and its English patternMMM d, yyyywas applied with French month names:août 1, 1965, a good reason to define the key for every supported locale. (The tag reference says that when no bundle defines the key at all,DateFormat.MEDIUMis used; that case was not run.) Theformatattribute,yyyy-MM-dd, ignores the bundle completely; use it for dates that must not change with the locale (an API, a hidden field). -
s:datedoes not use theformat.datemessage of the first row: it is the keystruts.date.formatthat the tag reads. Thes:textform and thes:dateform are two independent ways of formatting a date. -
The
nice="true"form prints a relative time. The tag reference lists the keys that control it (struts.date.format.past,.future,.seconds,.minutes,.hours,.days,.years); none of them ships translated. With English bundles61 years, 85 days agoprinted for the 1965 date (at the time of the run), and afterLocaleAction_de.propertiesdefinedstruts.date.format.past=vor {0}and astruts.date.format.yearschoice pattern, the German request printedvor 61 Jahren, 85 Tagen. -
The quotes of the Spanish pattern are doubled:
d 'de' MMMM 'de' yyyyprinted1 11 agosto 11 1965(the lettersdewere read as pattern letters afterMessageFormatremoved the single quotes);d ''de'' MMMM ''de'' yyyyprinted1 de agosto de 1965. A bundle value is processed byMessageFormatfirst and bySimpleDateFormatsecond, so a quoted literal needs two levels of quoting.
Number Formats in Forms and Round Trips
The Core Developer Guide warns about an input field that shows a number: a field filled with the Java default format is
wrong in a non-English locale, and it is worse when the user submits it, because type conversion is locale aware. The
Bookshelf has a form on price.jsp backed by PriceAction, a Double price property with @StrutsParameter on the
setter (see the parameter injection page for why 7.x needs it), and the message format.number={0,number,#,##0.0##}:
public class PriceAction extends ActionSupport {
private Double price = 1234.5;
public Double getPrice() { return price; }
@StrutsParameter
public void setPrice(Double price) { this.price = price; }
@Override
public void validate() {
if (price != null && price < 0) {
addFieldError("price", getText("price.negative"));
}
}
@Override
public String execute() { return SUCCESS; }
}
raw = <s:property value="price"/>
formatted = <s:property value="getText('format.number', {price})"/>
getFormatted = <s:property value="getFormatted('format.number', 'price')"/>
errors = <s:property value="fieldErrors"/>
...
<s:textfield key="price" value="%{getFormatted('format.number','price')}" theme="simple"/>
Observed with ?request_locale=de: raw = 1234,5, formatted = 1.234,5, getFormatted = 1.234,5, and the input
value 1.234,5. Posting price=1.234,5 under de parsed back to 1234.5. Three facts follow:
-
s:property value="price"already prints1234,5: Struts converts aDoubleto aStringwith the locale of the request, so the "raw" value is not the value ofDouble.toString. A hidden field that carries a number to a different system must be formatted explicitly. -
getText('format.number', {price})and the tag form formatting the same value use the same pattern;getFormatted(key, expression), added toActionSupportfor this purpose, is the variant the guide recommends for input fields because it supports conversion errors: withprice=abcposted (a conversion error) the field was re-displayed with the rejected textabcinstead of a formatted default. -
A pattern with grouping (
#,##0.0##) is a data-entry trap, because the comma is a group separator in one locale and the decimal separator in another. The input1,234.5underen_USparsed as 1234.5 and1.234,5underdeparsed as 1234.5. Print the same format that you accept (hencegetFormatted) and state the expected format in a hint.
Validation and Conversion Messages Are Localized Too
The same lookup localizes errors. PriceAction.properties and PriceAction_de.properties hold
price.negative (the text added by validate()) and invalid.fieldvalue.price (the text shown when the string cannot
be converted to a Double):
# PriceAction.properties
price=Price
format.number={0,number,#,##0.0##}
price.negative=The price cannot be negative
invalid.fieldvalue.price=Please enter the price as a number, for example {0}
# PriceAction_de.properties
price=Preis
price.negative=Der Preis darf nicht negativ sein
invalid.fieldvalue.price=Bitte den Preis als Zahl eingeben
Observed field errors:
| Request | fieldErrors |
|---|---|
|
|
|
|
|
|
|
|
The conversion error text is looked up under invalid.fieldvalue.<property> in the same hierarchy as every other key,
so one bundle holds the label, the validation message and the conversion message of a field. The placeholder in
the English text stayed as the literal {0}: in this run the field-specific conversion message was shown as written,
while the framework’s default (xwork.default.invalid.fieldvalue) does contain {0} for the field name. Do not rely on a
placeholder in an invalid.fieldvalue.* message being filled.
The type conversion and validation pages of this section describe the rest of the mechanism.
Struts 1 Compared
The books of the section teach localization with Struts 1: Locale, ResourceBundle, MessageFormat, MessageResources
declared with <message-resources parameter="..."/> in struts-config.xml, ActionMessages and ActionErrors keyed by
message resource keys, and the locale-sensitive bean:message and html tags. The concepts carry over; the mechanics
do not:
| Struts 1 (the books) | Struts 7.4.0 |
|---|---|
One |
One bundle per action, interface, base class and package, plus a list of global bundles in |
The locale is stored in the session by the |
The |
|
|
|
|
|
|
|
The same, with the additional |
The Core Developer Guide offers the same shortcut for a Struts 1 team: create ActionSupport.properties in
org/apache/struts2 and put the common texts there, which works as long as the actions extend ActionSupport.
Security and the Localized-Text Caches
Because the locale of a request and the arguments of a message are values from the client, localization is an attack surface, and two bulletins of the 7.x line concern it:
| Bulletin | Affected versions | What it is about |
|---|---|---|
S2-074 (CVE-2026-73635) |
7.0.0 through 7.2.1, 6.0.0 through 6.10.0 |
The localized-text caches grew without bound when the locale came from the request. An unauthenticated client could exhaust the heap. Fixed in 7.3.0 (6.11.0 on the 6.x line): the caches are bounded; workaround: set |
S2-078 (CVE-2026-104714) |
7.0.0 through 7.3.0, 6.0.0 through 6.11.0 |
A message formatter was shared between concurrent requests; a message that formats a date or time ( |
Source: Security Bulletin S2-074
Source: Security Bulletin S2-078
The Bookshelf runs on 7.4.0, so both are fixed, and the date messages of its bundles ({0,date,dd.MM.yyyy}) are the
very construct S2-078 is about; a project that cannot yet move to 7.4.0 should apply the stated workaround. S2-074 is
the reason for the three constants that the Core Developer Guide documents for 6.11 and 7.3:
| Constant | Default | Effect |
|---|---|---|
|
|
The cache implementation of the localized-text provider: |
|
|
Maximum entries of each cache (resolved bundles, missing-bundle misses, message formats, class and package hierarchy lookups); eviction costs only a recompute and cannot return a stale message. Raise it when the application resolves more than 10 000 distinct keys |
|
|
When |
The last constant was tested with a variant WAR that sets struts.locale.validateRequestLocale=true and no
struts.locale: Accept-Language: xx-ZZ and tlh printed en_US (the default) on both the default stack and the
accept_language action, while de-CH printed de_CH; the unmodified application printed xx_ZZ and tlh for the
same headers (see the experiment table). With 7.x on a public site, true is the recommended setting; a fixed
struts.locale is the other safe choice when the application is not multilingual.
One rule from the experiments completes the picture: a message is evaluated as OGNL when it contains %{...} (the
title.line message printed Title: Dune), so the contents of a bundle must be under the control of the
application and never built from user input.
A Custom TextProvider
The text provider is an extension point. LocalizedTextProvider is the lookup service (the findText and
findResourceBundle methods listed by javap), TextProvider is the view of it that an action sees, and
TextProviderFactory creates the TextProvider for a class (StrutsTextProviderFactory by default), which is why the
Core Developer Guide says both interfaces must be implemented to change where messages come from (a database, a CMS, a
remote service). A custom implementation is registered with <bean> elements and selected with the constants
struts.localizedTextProvider and struts.textProviderFactory. The shipped implementations are
StrutsLocalizedTextProvider (the hierarchy walk described above), GlobalLocalizedTextProvider (the
global-only provider), CompositeTextProvider, DefaultTextProvider and TextProviderSupport, all in
org.apache.struts2.text. The text-provider application of the struts-examples repository shows a small
implementation.
Source: struts-examples — text-provider
Worked Example: A Bookshelf Page in Four Languages
Putting the pieces together, the following is the order in which a request to GET /locale?request_locale=de is
processed in the Bookshelf, with the files that contribute:
-
The Servlet container hands the request to
StrutsPrepareAndExecuteFilter, which creates theActionContextwith the locale fromstruts.localeorAccept-Language. -
The
i18ninterceptor findsrequest_locale=de, validates it, writesdeto the session underWW_TRANS_I18N_LOCALE, replaces the locale of theActionContextand removes the parameter. -
LocaleActionruns and returnssuccess, andlocale.jspis rendered with the action on the value stack. -
<s:text name="book.title"/>asksfindText(LocaleAction.class, "book.title", de): step 1 findsLocaleAction_de.properties, so the answer isBücher. -
<s:text name="format.money">reads the pattern{0,number,#,##0.00} €from the same file andMessageFormatrenders1.234,50 €. -
<s:date name="published"/>readsstruts.date.format(dd.MM.yyyy) and prints01.08.1965. -
The next request in the session has no parameter; the interceptor reads the session value and the page is again German.
The three bundles that matter for the page are LocaleAction.properties (English and fallback), _de and _es; a
fourth language costs one file, and a language that has only some keys falls back, key by key, to the base file because
the JDK combines the candidate bundles (LocaleAction_fr.properties has no format.money and the page printed the
English pattern with the French number rules and the ¤ sign: partial files should be completed or the missing keys
inherit the base language).
Struts Messages Versus JSTL fmt
A Struts page that is rendered by JSP can also use the JSTL formatting library (fmt:message, fmt:formatDate), which has its own locale and bundle model: it reads the locale from the jakarta.servlet.jsp.jstl.fmt.locale configuration setting or the Accept-Language header, not from the ActionContext, and it finds bundles by basename rather than through the class-based search of getText. A request_locale change made by the i18n interceptor is therefore not seen by fmt:message unless the same locale is also passed to it with fmt:setLocale. The safe rule is to use one mechanism per page: s:text and getText for texts that belong to an action, and the JSTL library, described on the JSTL i18n & Messages page, only in plain JSP pages that are not served by an action.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A page shows the key ( |
The key is in no bundle of the lookup order, or the page was rendered without an action (a JSP opened directly has no action to start the search from) |
Render the page through an action; put the key in the action, package or global bundle; use |
|
The value is a |
Write |
|
A language-only locale ( |
Use |
The Spanish date prints |
|
Double the quotes in the bundle |
The |
|
Define |
The language links do nothing (observed with the cookie and request modes) |
The parameter name belongs to the storage mode: |
Use the parameter name of the configured mode |
The browser header selects an unsupported locale ( |
|
Set |
A request in a locale with no file shows the language of the server instead of the base file |
|
Ship a complete base file in the main language and set |
|
Step 6 peels only the first segment and looks the rest up in the type of that property; a longer key is not shortened from the right |
Define the full key in a bundle of the lookup order, or put the tail in the bundle of the property’s class |
The |
|
Use |
Labels of |
No bundle in the order has |
Add the key to the action, package or global bundle |
A message shows markup (observed with |
Messages are not escaped, and |
Escape in the view, |
The heap grows with the number of distinct locales sent by clients |
The localized-text caches were unbounded before 6.11 and 7.3 (S2-074) |
Upgrade; keep |
A date in a message shows another user’s value, or fails, under load |
The shared formatter of S2-078, before 7.4.0 and 6.12.0 |
Upgrade; or format the value first and pass the string |
Non-ASCII text turns into question marks or mojibake |
Properties files in different encodings, or |
Keep every file UTF-8; the default constant is |
What Changed Recently
| Version | Change |
|---|---|
2.6 |
|
6.11.0 and 7.3.0 |
The localized-text caches are bounded and configurable ( |
7.0.0 |
Package move: |
7.4.0 and 6.12.0 |
The shared message formatter that leaked date values between concurrent requests is fixed (S2-078) |
Where the Books Are Outdated
The books of the section teach the right model: a bundle per action and per package, getText, MessageFormat
parameters, a locale held for the user, and bundles that fall back from _de_AT to _de to the base. What changed:
| In the books | Struts 7.4.0 |
|---|---|
Struts 1 |
|
The bundle search order as a list of four or five steps |
Seven steps; the model ( |
|
|
|
UTF-8 |
|
Four storages, each with its own parameter ( |
A single global |
|
|
Still available; prefer package and global bundles, which do not need a tag wrapper |
A locale taken from the browser without a limit |
Bounded caches, |
No mention of formatter thread safety |
S2-078 (messages that format dates) fixed in 7.4.0 |
Inconsistencies in the Official Documentation
Collected while verifying this page against the 7.4.0 jar and source; none of them changes behaviour, but each can mislead:
-
The Localization page says the default action class defined by
default-class-refiscom.opensymphony.xwork2.ActionSupport; in 7.4.0struts-default.xmlnamesorg.apache.struts2.ActionSupport, which is the class whose.propertiesfile is meant. -
The Localization page says step 6 is to "search up the i18n message key hierarchy itself" without saying that this is a child-property lookup in the type of the first key segment.
-
The I18n Interceptor page says a request to
foo.action?request_locale=en_USstores the locale; its parameter table namesrequest_cookie_localeandrequest_only_locale, but the page does not say that the cookie and request storage modes react only to their own parameter (observed:request_localedid nothing in either). -
The Text tag page uses
text('aaa.bbb.ccc')insides:i18nwiths:property; on 7.4.0 the call returned an empty string, ands:textis what reads the pushed bundle. -
The Text tag page’s example
label="getText('person.lastName')"shows no%{...}; without it the attribute is a literal string, not an expression. -
The Formatting Dates and Numbers page sends the reader to "Sun’s Java API docs" and the Text tag page links to
java.sun.compages for Java 1.4.2; the current documentation is theMessageFormatJavadoc of the JDK in use. -
The Localization page’s Struts 1 comparison says the
ActionSupport.propertiesshortcut works "if all your actions subclass XWork2’s ActionSupport"; in 7.x the class isorg.apache.struts2.ActionSupportand XWork2 no longer exists as a package.
Summary
-
Texts live in
.propertiesfiles next to the classes: the action, its interfaces, its base classes, the model, thepackage.propertiesof every package up the tree, the child-property type and the global bundles, searched in that order until the first match. -
getText,s:text,s:i18nand thekeyattribute all use that lookup; a missing key prints the key, a default argument or the tag body replaces it, and the text is formatted withMessageFormat(so a single quote must be doubled) and evaluated for%{...}. -
The
i18ninterceptor chooses the locale:request_localestores it in the session,request_cookie_localein a cookie,request_only_localefor one request, andaccept_languagefollows the browser; each storage mode listens to its own parameter. -
Dates and numbers are
MessageFormatpatterns in the bundles;s:datereadsstruts.date.format, forms usegetFormatted, and conversion errors areinvalid.fieldvalue.<property>messages in the same bundles. -
The locale comes from the client: keep Struts at 7.4.0, keep the caches bounded, and set
struts.locale.validateRequestLocaleor a fixedstruts.localeon public sites.
References
-
Jakarta Standard Tag Library 3.0 — 8. Internationalization (I18N) Actions
-
Core Developer Guide — Localization, Resource Bundle Search Order
-
Core Developer Guide — Localization, Using the key attribute of UI tags
-
Core Developer Guide — Localization, Comparison with Struts 1
-
Core Developer Guide — Localization, Search in default bundles first
-
Core Developer Guide — Localization, Tuning the localized-text caches
-
Core Developer Guide — Localization, Validating the request locale
-
Core Developer Guide — Localization, Using only global bundles
-
Core Developer Guide — Localization, Custom TextProvider and TextProviderFactory
-
Core Developer Guide — Formatting Dates and Numbers, Defining Formats
-
Core Developer Guide — Formatting Dates and Numbers, Localizing form data with getText
-
Core Developer Guide — Formatting Dates and Numbers, Using getFormatted() with conversion support
-
Getting Started — Message Resource Files, Struts Key Attribute
-
Getting Started — Message Resource Files, Package Level Properties