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

s:text name="key", s:property value="getText('key')", s:i18n with s:text

format.money

Form labels

The key attribute of the UI tags (s:textfield key="price") loads the label from the bundle and uses the key as the field name

price

Validation and conversion messages

addFieldError("price", getText("price.negative")); conversion errors use invalid.fieldvalue.<field> (see the type conversion page)

price.negative, invalid.fieldvalue.price

Dates in tags

s:date reads struts.date.format (and the struts.date.format.* keys for nice="true") from the bundles

struts.date.format

Framework messages

The default bundles org/apache/struts2/struts-messages and org/apache/struts2/xwork-messages that ship in struts2-core

xwork.default.invalid.fieldvalue

Action code

Any getText(...) overload of ActionSupport, which implements TextProvider

greet

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 in de_AT the JDK tries _de_AT, _de and 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 for zz therefore ends in LocaleAction.properties; on a server whose default locale is German it would end in LocaleAction_de.properties. Setting -Duser.language on the server, or shipping a base file in the application’s main language, makes the result predictable.

  • Since Java 9 a .properties bundle 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.properties is saved as UTF-8 (Bücher) and LocaleAction_fr.properties as ISO-8859-1 (the byte 0xE9 for é), and both printed correctly on Java 21. The books of the section tell the reader to write é escapes or to run native2ascii; 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:

flowchart TD Q["findText for key K, locale L"] --> S1["1 ActionClass.properties"] S1 -->|miss| S2["2 each interface and sub-interface of the action"] S2 -->|miss| S3["3 each base class up to Object"] S3 -->|miss| S4{"action is ModelDriven"} S4 -->|yes| S4a["4 model class: steps 1 to 3 again for the model"] S4 -->|no| S5 S4a -->|miss| S5["5 package.properties of the action package and every parent package"] S5 -->|miss| S6["6 child property: key a.b looks up b in the class of property a"] S6 -->|miss| S7["7 global bundles: struts.custom.i18n.resources, struts-messages, xwork-messages"] S7 -->|miss| D["default message, or the key itself"] S1 -->|hit| F["format with MessageFormat and OGNL"] S2 -->|hit| F S3 -->|hit| F S4a -->|hit| F S5 -->|hit| F S6 -->|hit| F S7 -->|hit| F

The same seven steps as a table, with the file names of the Bookshelf:

Table 1. Resource bundle search order of StrutsLocalizedTextProvider (findText), first match wins
Step Where Detail

1

ActionClass.properties

The properties file with the simple class name of the action, in the same package; ShelfAction.properties for org.example.bookshelf.ShelfAction

2

Interface.properties

Every interface and sub-interface of the action; the Bookshelf’s Titled.properties

3

BaseClass.properties

Each superclass in turn, "all the way to Object.properties`"; `BaseBookAction.properties, then the same for ActionSupport

4

The model, when the action implements ModelDriven

Steps 1 to 3 are repeated for the class of the object returned by getModel(); Book.properties

5

package.properties

The one in the package of the class, then the one of each parent package up to the root of the class path; org/example/bookshelf/package.properties, org/example/package.properties

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

struts.custom.i18n.resources (comma separated list), then the struts-messages and xwork-messages bundles of the framework

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:

  • ib exists 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.

  • mb exists in BaseBookAction.properties and in the model’s Book.properties, and the base class wins, because the model is consulted only after the whole class hierarchy of the action.

  • pk shows that the nearest package.properties hides the one of the parent package, while pk.parent shows that a key missing from the nearest file is still found higher up the package tree.

  • key.hier is defined in ShelfAction.properties and in global.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:

Table 2. Constants that change the bundle search
Constant Default Effect

struts.i18n.search.defaultbundles.first

false

Since 2.6 the global bundles can be searched before the class hierarchy. With true the lookup finds a key that the global bundle defines without walking the hierarchy first, which the documentation says can improve performance; the price is that a global key hides the same key in a more specific bundle. Observed with true: lvl.1 to lvl.6 all printed level 7: global.properties and key.hier printed found at global level, the opposite of the default order above, while keys that only a class or package file defines (ib, mb, pk) were still found

struts.localizedTextProvider

the hierarchy provider (struts)

global-only selects GlobalLocalizedTextProvider, which never scans classes or packages and reads only the global bundles; for applications that keep all texts in struts.custom.i18n.resources. Observed with global-only: every lvl.n printed level 7: global.properties, ib, mb and pk printed the bare key, and book.title of the locale action printed book.title, because LocaleAction.properties was never consulted

struts.i18n.reload

false

Development-time reloading of bundles; the sample default.properties ships it commented out, and it should stay off in production (see the configuration page of this section)

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           = &lt;b&gt;bold&lt;/b&gt; &amp; &quot;quoted&quot;
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:text and getText give the same text; s:text is the idiom inside a page and getText is 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 the s:param children, from the list of the OGNL expression {reader, count} or from the Java array. A Number argument is formatted for the request locale, so the integer 1234 prints 1,234; the same value passed as a String (the Java example, String.valueOf(getCount())) prints 1234 because a string is not a number to MessageFormat.

  • quote and quote.args.bad lose their apostrophe: the text is run through java.text.MessageFormat, where a single quote starts a quoted section. It's a book printed Its a book even with no arguments, and It's book number {0} printed Its book number {0} because everything after the quote is literal, including the placeholder. The message must be written It''s book number {0} (two single quotes), which is the single most common i18n bug of a new Struts project and is the reason the s:date pattern 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. The s:text tag has the attributes escapeHtml, escapeXml, escapeJavaScript and escapeCsv (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:text and getText(key) both print the key itself ([no.such.key]), which is how an untranslated screen looks in production; getText(key, defaultValue) and the body of s:text supply a default that is used instead; hasKey(key) answers false without 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 the s:text inside it read ib from Titled.properties (the interface file, because the bundle named by the tag is searched first). The last line is a trap: getText('ib') inside s:i18n still asks the action and printed the key, while text('ib') returned nothing; the bundle named by the tag was reachable only through s:text. The tag reference’s example uses text('aaa.bbb.ccc') inside the tag; on 7.4.0 with s:property that call printed an empty string.

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.

Table 3. The TextProvider methods of ActionSupport (Struts 7.4.0)
Method Use

hasKey(String)

true when the key resolves anywhere in the search order

getText(String key)

The message, or the key when nothing is found

getText(String key, String defaultValue)

The message, or the default; the default is also run through the formatter

getText(String key, String defaultValue, String arg)

One argument, for example a field name

getText(String key, List<?> args) and getText(String key, String[] args)

Positional parameters; a List keeps its element types, so numbers and dates are formatted by locale

getText(String key, String defaultValue, List<?> args) and getText(String key, String defaultValue, String[] args)

Both

getText(..., ValueStack stack) (two overloads)

The same lookup against another value stack, used by tags and by code that runs outside an action

getTexts() and getTexts(String bundleName)

The ResourceBundle itself, for code that needs the whole bundle

getFormatted(String key, String expression)

Formats the value of an OGNL expression with a message format; see the formatting section

getLocale()

The locale of the request, from LocaleProvider

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.

Table 4. Parameters of org.apache.struts2.interceptor.I18nInterceptor
Parameter Default Meaning

parameterName

request_locale

Request parameter that switches the locale and stores it (in the session by default)

requestCookieParameterName

request_cookie_locale

Request parameter that switches the locale and stores it in a cookie

requestOnlyParameterName

request_only_locale

Request parameter that switches the locale for this request only

attributeName

WW_TRANS_I18N_LOCALE

Session key (and cookie name) of the stored locale

localeStorage

session

Where the locale is stored: session, cookie, request or accept_language

supportedLocale

none

Comma separated list of the locales the application supports; matched against Accept-Language (since 2.6)

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):

Table 5. Observed behaviour of the default i18n interceptor (GET /g9i18n/locale...)
Request Result

No parameter, no header

locale = en_US, session key empty, title = Books, price = $1,234.50, date (s:text) = 08/01/1965

?request_locale=de

locale = de, session key de, title = Bücher, accent = Käse über, price = 1.234,50 €, date (s:text) = 01.08.1965; the next request in the same session without a parameter still answers de

?request_locale=es

locale = es, title = Libros, accent = café ñandú, price = 1.234,50 €, date (s:text) = 01/08/1965

?request_locale=fr

locale = fr, title = Livres, accent = café (from the ISO-8859-1 file), price = 1 234,50 ¤ (see the pitfalls)

?request_locale=de_AT

locale = de_AT; the texts come from LocaleAction_de.properties (the de_AT file does not exist and the JDK uses the parent), and price printed 1 234,50 €, with the Austrian group separator of the JDK locale data

?request_locale=zz or an invalid value

The interceptor refuses it: locale = en_US and the session key holds en_US

?request_only_locale=es

locale = es for this response only; the next request is en_US and the session key stays empty

Header Accept-Language: de, no parameter

locale = de, session key empty: the browser’s locale is used, but not stored

Header Accept-Language: xx-ZZ

locale = xx_ZZ: an unknown locale is accepted on this path (see the validation constant below)

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:

Table 6. The four storage modes
localeStorage What was observed When to choose it

session (default)

?request_locale=de is stored under WW_TRANS_I18N_LOCALE in the HttpSession and used by every later request of that session.

Logged-in applications and anything that already has a session

cookie

?request_cookie_locale=de answered Set-Cookie: WW_TRANS_I18N_LOCALE=de; Expires=...; Max-Age=1209600 (two weeks, Path the context) and the next request, carrying only the cookie, printed locale = de. The parameter name request_locale did nothing for this action: it printed en_US while the document’s parameter table names request_cookie_locale for the cookie

Public sites without a session; a choice that should survive a browser restart

request

?request_only_locale=de printed locale = de; the next request printed en_US. request_locale=de printed en_US

One-off rendering (printing a page in another language, an e-mail preview)

accept_language

Accept-Language: de-DE,de;q=0.9 printed de and Accept-Language: fr printed fr; ?request_locale=es with Accept-Language: fr printed fr

Pure browser negotiation, no switch at all; supportedLocale is meant to narrow it, see below

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"/>:

Table 7. Behaviour with struts.locale=de (observed)
Request Locale

Default stack, header Accept-Language: fr

de (the header is ignored)

Default stack, ?request_locale=es

es (the interceptor still switches)

locale-accept action, header Accept-Language: es

es (the accept_language storage reads the header itself)

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=...):

Table 8. The same view in four locales
Locale price (format.money) s:text format.date s:date (struts.date.format) s:date format="yyyy-MM-dd"

en_US

$1,234.50

08/01/1965

Aug 1, 1965

1965-08-01

de

1.234,50 €

01.08.1965

01.08.1965

1965-08-01

es

1.234,50 €

01/08/1965

1 de agosto de 1965

1965-08-01

fr

1 234,50 ¤

01/08/1965

août 1, 1965

1965-08-01

Points to take from the table:

  • {0,number,currency} uses the currency of the locale’s country. fr has no country, so the JDK prints the generic currency sign ¤; use fr_FR (or a German-style fixed pattern with a literal €, as the Bookshelf does for de and es) when a currency symbol is wanted. A language-only locale is enough for texts and wrong for money.

  • The s:date tag reads struts.date.format from the bundle of the current locale. LocaleAction_fr.properties does not define it, so the lookup fell back to the base file and its English pattern MMM d, yyyy was 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.MEDIUM is used; that case was not run.) The format attribute, yyyy-MM-dd, ignores the bundle completely; use it for dates that must not change with the locale (an API, a hidden field).

  • s:date does not use the format.date message of the first row: it is the key struts.date.format that the tag reads. The s:text form and the s:date form 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 bundles 61 years, 85 days ago printed for the 1965 date (at the time of the run), and after LocaleAction_de.properties defined struts.date.format.past=vor {0} and a struts.date.format.years choice pattern, the German request printed vor 61 Jahren, 85 Tagen.

  • The quotes of the Spanish pattern are doubled: d 'de' MMMM 'de' yyyy printed 1 11 agosto 11 1965 (the letters de were read as pattern letters after MessageFormat removed the single quotes); d ''de'' MMMM ''de'' yyyy printed 1 de agosto de 1965. A bundle value is processed by MessageFormat first and by SimpleDateFormat second, 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 prints 1234,5: Struts converts a Double to a String with the locale of the request, so the "raw" value is not the value of Double.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 to ActionSupport for this purpose, is the variant the guide recommends for input fields because it supports conversion errors: with price=abc posted (a conversion error) the field was re-displayed with the rejected text abc instead 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 input 1,234.5 under en_US parsed as 1234.5 and 1.234,5 under de parsed as 1234.5. Print the same format that you accept (hence getFormatted) 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:

Table 9. POST /g9i18n/price with different values and locales
Request fieldErrors

price=abc and request_locale=de

{price=[Bitte den Preis als Zahl eingeben]}

price=abc (English)

{price=[Please enter the price as a number, for example {0}]}

price=-3 (English)

{price=[The price cannot be negative]}

price=1,234.5 (English)

{} and raw = 1234.5

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:

Table 10. From Struts 1 localization to Struts 7
Struts 1 (the books) Struts 7.4.0

One ApplicationResources.properties registered in struts-config.xml, with _xx variants

One bundle per action, interface, base class and package, plus a list of global bundles in struts.custom.i18n.resources; a project-wide file is an ActionSupport.properties or a global bundle

The locale is stored in the session by the RequestProcessor and changed by an action that calls setLocale

The i18n interceptor stores it and request_locale switches it; no action is needed

<bean:message key="..."/>, <html:text property="..."/> labels

s:text, getText, the key attribute of the UI tags

ActionMessages/ActionErrors with ActionMessage(key, args)

addActionMessage(getText(key, args)), addFieldError(field, getText(key)), both through ValidationAware

MessageResources.getMessage(locale, key, args)

getText(key, args), LocalizedTextProvider.findText(...)

MessageFormat in a properties file, one placeholder syntax

The same, with the additional %{...} OGNL evaluation of the value

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:

Table 11. Localization-related security bulletins
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 struts.locale to a fixed value

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 ({0,date,...}, {0,time}) could show one user’s value in another user’s response or fail. Fixed in 7.4.0 (6.12.0 on the 6.x line); workaround: format the value before passing it

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:

Table 12. Constants for the localized-text caches and the request locale
Constant Default Effect

struts.i18n.cacheType

wtlfu

The cache implementation of the localized-text provider: basic (unbounded ConcurrentHashMap), lru or wtlfu (Window TinyLFU); the same abstraction as the OGNL caches

struts.i18n.cacheMaxSize

10000

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

struts.locale.validateRequestLocale

false

When struts.locale is unset, the locale derived from Accept-Language is checked against the JVM’s available locales; an unavailable one falls back to the default locale. The i18n interceptor already checks its own request_locale

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.

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:

  1. The Servlet container hands the request to StrutsPrepareAndExecuteFilter, which creates the ActionContext with the locale from struts.locale or Accept-Language.

  2. The i18n interceptor finds request_locale=de, validates it, writes de to the session under WW_TRANS_I18N_LOCALE, replaces the locale of the ActionContext and removes the parameter.

  3. LocaleAction runs and returns success, and locale.jsp is rendered with the action on the value stack.

  4. <s:text name="book.title"/> asks findText(LocaleAction.class, "book.title", de): step 1 finds LocaleAction_de.properties, so the answer is Bücher.

  5. <s:text name="format.money"> reads the pattern {0,number,#,##0.00} € from the same file and MessageFormat renders 1.234,50 €.

  6. <s:date name="published"/> reads struts.date.format (dd.MM.yyyy) and prints 01.08.1965.

  7. 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

Table 13. Localization pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 with Struts 7.4.0)
Symptom Cause Fix

A page shows the key (book.title) instead of the text (observed)

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 hasKey to branch

It's book number {0} prints Its book number {0} (observed)

The value is a MessageFormat pattern and a single quote is its escape character

Write It''s book number {0}; in a date pattern quote the literals twice (d ''de'' MMMM)

price = 1 234,50 ¤ (observed)

A language-only locale (fr) has no currency

Use fr_FR or a fixed pattern with the currency sign

The Spanish date prints 1 11 agosto 11 1965 (observed)

MessageFormat removed the quotes around the literal text before SimpleDateFormat saw it

Double the quotes in the bundle

The s:date prints a French month in the English order, août 1, 1965 (observed with fr)

struts.date.format is missing from LocaleAction_fr.properties, so the base file’s English pattern is used

Define struts.date.format for every supported locale

The language links do nothing (observed with the cookie and request modes)

The parameter name belongs to the storage mode: request_cookie_locale for cookie, request_only_locale for request, request_locale only for session

Use the parameter name of the configured mode

The browser header selects an unsupported locale (xx_ZZ) (observed)

supportedLocale does not replace an unsupported browser locale, and the dispatcher takes the header as it is

Set struts.locale.validateRequestLocale=true, or struts.locale

A request in a locale with no file shows the language of the server instead of the base file

ResourceBundle.getBundle falls back to the JVM default locale before the base file; on a server whose default locale is German the "fallback" is the German file

Ship a complete base file in the main language and set user.language explicitly

key.hier.sub.deep printed the key although key.hier exists (observed)

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 s:i18n tag’s bundle is not used by getText (observed)

getText asks the action; the bundle pushed by s:i18n is reachable through s:text

Use s:text inside s:i18n

Labels of s:textfield key="x" show the bare x (observed)

No bundle in the order has x for this action

Add the key to the action, package or global bundle

A message shows markup (observed with html=<b>...)

Messages are not escaped, and escapeHtml of s:text is off by default

Escape in the view, escapeHtml="true"

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 struts.i18n.cacheMaxSize and validate the locale

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 struts.i18n.encoding and the page encoding disagree

Keep every file UTF-8; the default constant is struts.i18n.encoding=UTF-8; set pageEncoding in the JSP (see the character encoding page)

What Changed Recently

Table 14. Localization changes by Struts version (from the Core Developer Guide pages, the 7.4.0 jar and the bulletins)
Version Change

2.6

supportedLocale on the i18n interceptor; struts.i18n.search.defaultbundles.first (WW-5112)

6.11.0 and 7.3.0

The localized-text caches are bounded and configurable (struts.i18n.cacheType, struts.i18n.cacheMaxSize); struts.locale.validateRequestLocale (S2-074)

7.0.0

Package move: com.opensymphony.xwork2.* became org.apache.struts2.* (org.apache.struts2.text.TextProvider, org.apache.struts2.locale.LocaleProvider, org.apache.struts2.ActionSupport); ActionSupport.properties belongs in org/apache/struts2/

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:

Table 15. Localization material in the books and what is current
In the books Struts 7.4.0

Struts 1 MessageResources registered in struts-config.xml, bean:message, ActionErrors

ActionSupport and TextProvider, s:text, ValidationAware; the RequestProcessor locale handling is the i18n interceptor

The bundle search order as a list of four or five steps

Seven steps; the model (ModelDriven) step and the key-hierarchy step exist, and both appear in the 7.4.0 code

com.opensymphony.xwork2.ActionSupport, com.opensymphony.xwork2.TextProvider

org.apache.struts2.ActionSupport, org.apache.struts2.text.TextProvider

é escapes and native2ascii for non-ASCII text

UTF-8 .properties files, read directly since Java 9 (with an ISO-8859-1 fallback)

request_locale as the one switch

Four storages, each with its own parameter (request_locale, request_cookie_locale, request_only_locale) and Accept-Language negotiation with supportedLocale

A single global application.properties

struts.custom.i18n.resources for global bundles and org/apache/struts2/ActionSupport.properties for texts shared by every action

bundle per tag (s:i18n) as the way to share texts

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, struts.locale.validateRequestLocale and fixed struts.locale as defences

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-ref is com.opensymphony.xwork2.ActionSupport; in 7.4.0 struts-default.xml names org.apache.struts2.ActionSupport, which is the class whose .properties file 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_US stores the locale; its parameter table names request_cookie_locale and request_only_locale, but the page does not say that the cookie and request storage modes react only to their own parameter (observed: request_locale did nothing in either).

  • The Text tag page uses text('aaa.bbb.ccc') inside s:i18n with s:property; on 7.4.0 the call returned an empty string, and s:text is 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.com pages for Java 1.4.2; the current documentation is the MessageFormat Javadoc of the JDK in use.

  • The Localization page’s Struts 1 comparison says the ActionSupport.properties shortcut works "if all your actions subclass XWork2’s ActionSupport"; in 7.x the class is org.apache.struts2.ActionSupport and XWork2 no longer exists as a package.

Summary

  • Texts live in .properties files next to the classes: the action, its interfaces, its base classes, the model, the package.properties of 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:i18n and the key attribute all use that lookup; a missing key prints the key, a default argument or the tag body replaces it, and the text is formatted with MessageFormat (so a single quote must be doubled) and evaluated for %{...}.

  • The i18n interceptor chooses the locale: request_locale stores it in the session, request_cookie_locale in a cookie, request_only_locale for one request, and accept_language follows the browser; each storage mode listens to its own parameter.

  • Dates and numbers are MessageFormat patterns in the bundles; s:date reads struts.date.format, forms use getFormatted, and conversion errors are invalid.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.validateRequestLocale or a fixed struts.locale on public sites.

References