JSTL i18n & Messages

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 the Jakarta Standard Tag Library (Jakarta Tags) 3.0, chapter 8 "Internationalization (i18n) Actions" and the configuration data of chapter 2, together with Jakarta Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Servlet 6.1 (Jakarta EE 11), as implemented by Apache Tomcat 11.0.26 on Java 21 with the Tags implementation org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1 and the API jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2. Everything was verified on 2026-10-08: the rules against the specification and the Javadoc, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs shown are the outputs of those runs. The fmt: library is the part of JSTL that lets a page keep its words out of its markup: labels, messages and sentences live in resource bundles, one per language, and the page asks for them by key. This page covers the actions that choose a language and fetch text (fmt:setLocale, fmt:bundle, fmt:setBundle, fmt:message, fmt:param, fmt:requestEncoding), the algorithm that picks a bundle for a visitor, and the configuration settings that steer it. Numbers, currencies, dates and time zones use the same locale machinery but belong to the JSTL Formatting page of this section. The running scenario is the Bookshelf, now in English and Spanish.

Status: Archived Tutorial, Current Library

The fmt: library is not deprecated and not removed: Tags 3.0 is the current version of the specification, it ships in Jakarta EE 10 and 11, and its implementation is the same one Tomcat, Jetty and GlassFish applications load today. What is archived is the teaching material. The Jakarta EE Tutorial moved its Jakarta Pages and Jakarta Tags chapters to an "Archived" area, and its remaining chapter on internationalization, "Internationalizing and Localizing Web Applications", is written around Jakarta Faces (faces-config.xml, f:loadBundle) and was last updated for Jakarta EE 9.1; the only JSTL content in it is one sentence saying that a component can set the locale with fmt:setLocale. The last complete official tutorial for the JSP flavour, the Java EE 5 Tutorial, uses javax.* names. The consequence is that the specification chapter and the Javadoc of jakarta.servlet.jsp.jstl.fmt are the primary sources, and they are what this page follows.

Table 1. Where each piece of the i18n story lives
Piece Where it is defined Status on Jakarta EE 11

fmt:setLocale, fmt:bundle, fmt:setBundle, fmt:message, fmt:param, fmt:requestEncoding

Tags 3.0, chapter 8

Current, unchanged since JSTL 1.0 apart from the package and URI rename

LocalizationContext, LocaleSupport, Config

Tags 3.0 Javadoc, package jakarta.servlet.jsp.jstl.fmt and jakarta.servlet.jsp.jstl.core

Current

Resource bundles (ResourceBundle, PropertyResourceBundle)

Java SE 21, java.util

Current; .properties files are read as UTF-8 since Java 9

Parameter substitution and plural forms

java.text.MessageFormat

Current; the substitution engine behind fmt:param

Page character encoding, response and request encoding

Pages 4.0 chapter 4, Servlet 6.1

See the Character Encoding page

Number, date and time zone formatting

Tags 3.0, chapter 9

Covered by the formatting page; uses the locale chosen here

Faces localization

Jakarta Faces and the archived-era tutorial chapter

Out of scope here

Locale, Bundle, Basename and Key

The specification builds the library on four ideas, all taken from java.util.

  • A locale is a language, optionally with a country and a variant. The language is the lower-case two-letter ISO-639 code (es, fr), the country the upper-case two-letter ISO-3166 code (ES, CA). The semantics are those of java.util.Locale, so the legacy codes apply: he, yi and id are reported as iw, ji and in by Locale unless the JVM property java.locale.useOldISOCodes says otherwise.

  • A resource bundle holds locale-specific objects, here strings, each associated with a key.

  • A basename identifies a family of bundles that translate the same keys. The bundle for a locale is the basename plus a suffix: Messages is the root bundle, Messages_es the Spanish one, Messages_es_ES the Spanish of Spain.

  • A localization context is the pair the actions actually work with: a resource bundle and the locale for which it was found. It is the Java class jakarta.servlet.jsp.jstl.fmt.LocalizationContext.

The specification names two ways of internationalizing a page. The first is one page per locale with a controller servlet that dispatches to the right one, which suits pages where most of the content differs. The second isolates the locale-sensitive text in bundles and fetches it with the fmt: actions, which suits everything else and is what the rest of this page is about. The two combine: a controller can pick a page variant per locale and each variant still uses bundles for its labels.

The Bookshelf Used on This Page

All the examples are files of one Maven WAR, g5jstli18n.war, deployed under the context path /g5jstli18n on Tomcat 11.0.26 (plus one variant that differs only in web.xml, named where it appears). The WAR has the dependencies of the Getting Started page of this section: provided Servlet, Pages and EL APIs, plus the Jakarta Tags API 3.0.2 and its implementation 3.0.1. One servlet, DataServlet, mapped to /data/*, puts a list of Book records in the request and forwards to /WEB-INF/e/<name>.jsp, so /data/basic runs /WEB-INF/e/basic.jsp. Every request below was made with curl and an explicit Accept-Language header, because that header is what a browser uses to say which languages its user reads; the header is set with -H 'Accept-Language: es'.

The bundles are plain .properties files in src/main/resources/org/example/bookshelf/i18n/. They are saved as UTF-8 and contain the accented characters directly; Java 9 and later read them as UTF-8 (see the pitfalls section).

The root bundle (English), Messages.properties
shelf.title=My Bookshelf
shelf.count=You have {0} books on the shelf
shelf.summary={0} was written by {1} in {2}
shelf.summary.year={0} was written by {1} in {2,number,#}
form.label=Title
form.search=Search
com.example.labels.author=Author
com.example.labels.year=Year
The Spanish bundle, Messages_es.properties
shelf.title=Mi estantería
shelf.count=Tienes {0} libros en la estantería
shelf.summary={0} fue escrito por {1} en {2}
shelf.summary.year={0} fue escrito por {1} en {2,number,#}
form.label=Título
form.search=Buscar
com.example.labels.author=Autor
com.example.labels.year=Año
The servlet that feeds every page
@WebServlet("/data/*")
public class DataServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Book> books = List.of(
                new Book(1, "Dune", "Frank Herbert", 1965),
                new Book(2, "Cien años de soledad", "Gabriel García Márquez", 1967),
                new Book(3, "Neuromancer", "William Gibson", 1984));
        req.setAttribute("books", books);
        req.getRequestDispatcher("/WEB-INF/e" + req.getPathInfo() + ".jsp").forward(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doGet(req, resp);
    }
}

Declaring the Library

The i18n actions live in the fmt library with the URI jakarta.tags.fmt (Tags 3.0 renamed the URIs from http://java.sun.com/jsp/jstl/ to jakarta.tags.). The prefix fmt is a convention, not a rule.

<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>

The old URIs still work with the 3.0.1 implementation because the jar keeps the older TLD files (fmt-1_0.tld, fmt-1_0-rt.tld, fmt-1_1.tld, fmt.tld): uri="http://java.sun.com/jsp/jstl/fmt" and the JSTL 1.0 URI uri="http://java.sun.com/jstl/fmt" both rendered the Bookshelf title on Tomcat 11.0.26. The URI of the JSF-era namespace, http://xmlns.jcp.org/jsp/jstl/fmt, is not one of them and fails with JasperException: The absolute uri: [http://xmlns.jcp.org/jsp/jstl/fmt] cannot be resolved in either web.xml or the jar files deployed with this application. New code should use jakarta.tags.fmt.

The First Message: fmt:setBundle and fmt:message

The smallest internationalized page names a bundle once and then asks for keys. fmt:setBundle without a var makes the bundle the default localization context of the page, and fmt:message reads its key from that default.

A page whose text comes from bundles, basic.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<h1><fmt:message key="shelf.title"/></h1>
<p><fmt:message key="shelf.count"><fmt:param value="${books.size()}"/></fmt:message></p>
<ul>
<c:forEach var="b" items="${books}">
  <li><c:out value="${b.title}"/></li>
</c:forEach>
</ul>

With Accept-Language: es the page answers with the Spanish bundle, and with no Accept-Language header at all it answers with the root bundle:

<!-- curl -H 'Accept-Language: es' /g5jstli18n/data/basic -->
<h1>Mi estantería</h1>
<p>Tienes 3 libros en la estantería</p>

<!-- curl /g5jstli18n/data/basic -->
<h1>My Bookshelf</h1>
<p>You have 3 books on the shelf</p>

The response headers are part of the result. For the Spanish request Tomcat answered Content-Language: es and Content-Type: text/html;charset=UTF-8; for a request with Accept-Language: en-GB it answered Content-Language: en-GB; for a request without the header there was no Content-Language at all. The actions set the response locale as a side effect, which is the subject of the response section below.

The book titles are printed with c:out, never as a bare ${b.title}, because a title is data and not a message: bundles hold the application’s own text, and the data around it still has to be escaped (the security section shows why fmt:message is not an escaping tag).

fmt:message and fmt:param Reference

fmt:message looks a key up in the bundle of the localization context it determines and writes the text to the current JspWriter, or stores it in a scoped variable when var is given. It has three syntaxes: the key in an attribute and no body, the key in an attribute with fmt:param children, and the key as the (trimmed) body, optionally followed by fmt:param children.

Table 2. Attributes of fmt:message
Attribute Dynamic Type Meaning

key

yes

String

The message key to look up. If it is absent, the trimmed body is the key

bundle

yes

LocalizationContext

The localization context to read the key from; wins over everything else

var

no

String

Name of the scoped variable that receives the message instead of printing it

scope

no

String

Scope of var: page (default), request, session or application; allowed only together with var

Table 3. Attribute of fmt:param
Attribute Dynamic Type Meaning

value

yes

Object

The argument; when it is absent, the trimmed body content is the argument

The error handling is specified and was observed:

Table 4. Null and error handling of fmt:message
Situation Specified result Observed output (Tomcat 11.0.26)

The key is not in the bundle

The text ???key??? with the key between the question marks

???no.such.key???

The key is null or empty

??????, the message processed as undefined

??????

No resource bundle can be found for the context

???key??? for every key

???shelf.title??? inside fmt:bundle basename="...NoSuchBundle", and also in a page with no fmt:setBundle and no default context

scope without var

Constraint of the tag

Translation fails with Illegal scope attribute without var in "fmt:message" tag.

fmt:param outside a fmt:message

Constraint of the tag

Translation fails with <param> outside <message>

bundle given a String instead of a LocalizationContext

Type mismatch

Unable to convert string [org.example.bookshelf.i18n.Messages] to class [jakarta.servlet.jsp.jstl.fmt.LocalizationContext] for attribute [bundle]

None of these failures is an exception at request time except the translation-time ones, so a missing key is visible only as ??? in the page. A page test that greps for ??? catches most of them.

A bundle selected by variable, the key as a body, a stored message and the error outputs, bundle.jsp
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" var="msgs"/>
<p><fmt:message key="shelf.title" bundle="${msgs}"/></p>
<fmt:message key="shelf.title" bundle="${msgs}" var="t" scope="request"/>
<p>stored: ${requestScope.t}</p>
<p>body key: <fmt:message bundle="${msgs}">  shelf.title  </fmt:message></p>
<p>missing: <fmt:message key="no.such.key" bundle="${msgs}"/></p>
<p>empty key: <fmt:message key="" bundle="${msgs}"/></p>
<p>Mi estantería</p>
<p>stored: Mi estantería</p>
<p>body key: Mi estantería</p>
<p>missing: ???no.such.key???</p>
<p>empty key: ??????</p>

Parameters and MessageFormat

A message that contains variables is a java.text.MessageFormat pattern, and fmt:param supplies the arguments in the order of the tags. The specification states the mechanism exactly: with one or more fmt:param tags the message is passed to MessageFormat.applyPattern() and the values are collected in an Object[] for format(), with the MessageFormat locale set to the locale of the localization context (or, when the context has no locale, to the formatting locale of the page, or finally to the JVM default). Without any fmt:param the message is left untouched and MessageFormat is not used at all. That second rule explains several outputs below.

Parameters in a message, the number, the plural and the date cases, params.jsp
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<c:set var="b" value="${books[0]}"/>
<p><fmt:message key="shelf.summary"><fmt:param value="${b.title}"/><fmt:param value="${b.author}"/><fmt:param value="${b.year}"/></fmt:message></p>
<p><fmt:message key="shelf.summary.year"><fmt:param value="${b.title}"/><fmt:param value="${b.author}"/><fmt:param value="${b.year}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${0}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${1}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.copies"><fmt:param value="${1234}"/><fmt:param value="${b.title}"/></fmt:message></p>
<p><fmt:message key="shelf.count"><fmt:param><fmt:formatNumber value="${12345}"/></fmt:param></fmt:message></p>
<jsp:useBean id="now" class="java.util.Date"/>
<p><fmt:message key="shelf.due"><fmt:param value="${now}"/></fmt:message></p>

with these extra keys in Messages.properties (the Spanish file has the same keys translated):

shelf.copies=There {0,choice,0#are no copies|1#is one copy|1<are {0,number,integer} copies} of {1}
shelf.due=Due on {0,date,long}
Output with Accept-Language: en and with Accept-Language: es
<p>Dune was written by Frank Herbert in 1,965</p>
<p>Dune was written by Frank Herbert in 1965</p>
<p>There are no copies of Dune</p>
<p>There is one copy of Dune</p>
<p>There are 1,234 copies of Dune</p>
<p>You have 12,345 books on the shelf</p>
<p>Due on October 8, 2026</p>

<p>Dune fue escrito por Frank Herbert en 1.965</p>
<p>Dune fue escrito por Frank Herbert en 1965</p>
<p>Hay ninguna copia de Dune</p>
<p>Hay una copia de Dune</p>
<p>Hay 1.234 copias de Dune</p>
<p>Tienes 12.345 libros en la estantería</p>
<p>Vence el 8 de octubre de 2026</p>

Four lessons are visible in this output.

  • A number argument is formatted by MessageFormat with the grouping of the locale. The year 1965 printed as 1,965 in English and 1.965 in Spanish. The pattern {2,number,#} (a DecimalFormat pattern without grouping) prints 1965 in both. A year, an id or a postal code is not a quantity and needs that pattern, or a String argument.

  • The choice format gives plural forms (0#...|1#...|1<...), and the number inside it can use its own sub-format ({0,number,integer} printed 1,234). Spanish shows that the plural words are the translator’s business: the Spanish line reads awkwardly (Hay ninguna copia, where a translator would write No hay ninguna copia), which is the argument for putting the whole sentence, not a fragment, in the bundle and letting the translator own it. Plural rules of languages with more than two forms are beyond choice; ICU4J’s MessageFormat is the usual answer when they are needed.

  • A date argument can be given a style ({0,date,long}) and is then localized by the same locale: October 8, 2026 against 8 de octubre de 2026.

  • fmt:param accepts a body, so another action can compute the argument. fmt:formatNumber inside the fmt:param printed 12,345 and 12.345, using the same formatting locale as the message.

The apostrophe trap

MessageFormat uses the single quote to quote text, so an apostrophe in a message that is formatted has to be doubled, and an apostrophe in a message that is not formatted must not be. Because fmt:message calls MessageFormat only when there are fmt:param children, the same text needs different spelling in the two cases. The bundle for this example adds three keys:

shelf.apostrophe.plain=Don't panic
shelf.apostrophe.param=Don''t panic about {0}
shelf.apostrophe.broken=Don't panic about {0}
The three spellings with and without parameters, apostrophe.jsp and apos2.jsp
<p>1: <fmt:message key="shelf.apostrophe.plain"/></p>
<p>2: <fmt:message key="shelf.apostrophe.param"><fmt:param value="Dune"/></fmt:message></p>
<p>3: <fmt:message key="shelf.apostrophe.broken"><fmt:param value="Dune"/></fmt:message></p>
<p>4: <fmt:message key="shelf.apostrophe.broken"/></p>
<p>5: <fmt:message key="shelf.apostrophe.param"/></p>
<p>1: Don't panic</p>
<p>2: Don't panic about Dune</p>
<p>3: Dont panic about {0}</p>
<p>4: Don't panic about {0}</p>
<p>5: Don''t panic about {0}</p>

Line 3 is the failure: the single quote started a quoted section, so the apostrophe vanished and {0} was printed literally. Line 5 is the mirror image: the doubled apostrophe shows doubled when the message is not formatted. The rule that works is to double the apostrophe in every message that is ever used with a fmt:param, and to keep a message either always with parameters or never. A message whose parameter count may be zero should be given a dummy parameter or should be rewritten, because the same bundle line cannot satisfy both spellings.

Bundles and the Localization Context

An i18n action needs a localization context, and the specification fixes where it comes from. The first rule that applies wins.

Table 5. How fmt:message finds its localization context (Tags 3.0, section 8.2)
Order Source Example

1

The bundle attribute of fmt:message, a LocalizationContext object

<fmt:message key="x" bundle="${msgs}"/>

2

The innermost enclosing fmt:bundle

<fmt:bundle basename="..."> ... </fmt:bundle>

3

The default localization context, the configuration setting jakarta.servlet.jsp.jstl.fmt.localizationContext, set by fmt:setBundle without var, by a scoped configuration variable or by a context-param

<fmt:setBundle basename="..."/> earlier in the page

If the default setting is a LocalizationContext, its bundle is used as it is. If it is a String, the action builds its own context with that string as the basename, using the lookup algorithm of the next section.

flowchart TD msg["fmt:message key"] --> a{"bundle attribute present?"} a -- yes --> ctx1["use that LocalizationContext"] a -- no --> b{"inside a fmt:bundle?"} b -- yes --> ctx2["use the context of the innermost fmt:bundle"] b -- no --> c{"default localizationContext configured?"} c -- "LocalizationContext object" --> ctx3["use it as it is"] c -- "String basename" --> ctx4["build a context from the basename with the lookup algorithm"] c -- "nothing" --> none["no bundle: output is ???key???"] ctx1 --> out["look the key up, apply MessageFormat if fmt:param children exist"] ctx2 --> out ctx3 --> out ctx4 --> out

fmt:bundle and its prefix

fmt:bundle creates a localization context for its body: every fmt:message nested inside it, at any depth of markup, uses it (unless the message has its own bundle attribute). The prefix attribute is prepended to the key of every nested fmt:message, which saves typing for long, dotted keys. The body of fmt:bundle is processed normally and written to the page.

Table 6. Attributes of fmt:bundle and fmt:setBundle
Action Attribute Dynamic Type Meaning

fmt:bundle

basename

yes

String

The fully qualified resource name of the bundle, in class-name form with . separators and without suffix (org.example.bookshelf.i18n.Messages)

fmt:bundle

prefix

yes

String

Text prepended to the key of every nested fmt:message

fmt:setBundle

basename

yes

String

As above

fmt:setBundle

var

no

String

Scoped variable that receives the LocalizationContext; if absent, the context becomes the default localization context

fmt:setBundle

scope

no

String

Scope of var or of the default context; page is the default

A prefix for dotted keys, bundle.jsp
<fmt:bundle basename="org.example.bookshelf.i18n.Messages" prefix="com.example.labels.">
  <p><fmt:message key="author"/>, <fmt:message key="year"/></p>
</fmt:bundle>
<p>Author, Year</p>     <!-- no Accept-Language -->
<p>Autor, Año</p>       <!-- Accept-Language: es -->

If basename is null or empty, or no bundle is found, the context holds the null bundle, and every fmt:message then prints ???key???; there is no error.

fmt:setBundle: a variable or the default

fmt:setBundle with a var stores a LocalizationContext that other tags can pass in their bundle attribute. This is the way to use two bundles on one page, for instance the application’s messages and a separate bundle of error texts, without nesting fmt:bundle blocks. Without var, the context becomes the page’s default (or, with scope="session" or scope="application", the default of a wider scope).

The two uses of fmt:setBundle
<%-- 1. as a variable, used explicitly --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Errors" var="errors"/>
<fmt:message key="book.notFound" bundle="${errors}"/>

<%-- 2. as the default of the page, session or application --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" scope="application"/>

The default bundle for a whole application

Writing fmt:setBundle at the top of every page is repetitive, and the specification gives a declarative alternative: the context-parameter jakarta.servlet.jsp.jstl.fmt.localizationContext in web.xml, whose string value is a basename. Pages then use fmt:message with no bundle declaration at all.

A default bundle and a fallback locale in web.xml (the g5jstli18nb variant)
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
         version="6.1">
    <context-param>
        <param-name>jakarta.servlet.jsp.jstl.fmt.localizationContext</param-name>
        <param-value>org.example.bookshelf.i18n.Plain</param-value>
    </context-param>
    <context-param>
        <param-name>jakarta.servlet.jsp.jstl.fmt.fallbackLocale</param-name>
        <param-value>es</param-value>
    </context-param>
</web-app>
A page with no bundle declaration, ctx.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<h1><fmt:message key="greeting"/></h1>
<!-- variant WAR, Accept-Language: es  -->  <h1>Hola desde el paquete en español</h1>
<!-- variant WAR, Accept-Language: en-GB --> <h1>Hello from the English bundle</h1>
<!-- the base WAR, which has no context-param --> <h1>???greeting???</h1>

The last line shows the other side: without a fmt:setBundle, a fmt:bundle or the context-parameter, there is no default context, so the key is printed in question marks. The context-parameter holds only String values (the specification says so); an object context can be set per scope with fmt:setBundle or Config.set.

Choosing the Locale

The locale that picks the bundle (and, for the formatting actions, the number and date style) comes from one of two places, and the specification gives the first priority.

  • Application-based. The configuration setting jakarta.servlet.jsp.jstl.fmt.locale (the constant Config.FMT_LOCALE) holds a locale chosen by the application. It is set by fmt:setLocale, by Config.set from Java code, or by a context-param of the same name. It is the right choice when the user picks a language in the interface, or when the language is stored in the user’s profile.

  • Browser-based. If the setting is absent, the actions call ServletRequest.getLocales(), the ordered list built from the Accept-Language header, and try the locales in that order.

Either way an ordered list of preferred locales is fed to the bundle algorithm. One detail of the implementation matters in tests: the Util.getRequestLocales of the 3.0.1 implementation returns the locales of the request only when the request has an Accept-Language header. Without the header the list is empty, even though request.getLocales() alone would return the server’s default locale (it printed en_US in the test). A curl without the header therefore behaves like a visitor whose browser expresses no preference, and falls to the fallback locale or the root bundle.

fmt:setLocale

Table 7. Attributes of fmt:setLocale
Attribute Dynamic Type Meaning

value

yes

String or java.util.Locale

A string with a lower-case language and optionally an upper-case country, separated by - or _ (es, es-ES, es_ES). A null or empty value selects the JVM default locale

variant

yes

String

A vendor or browser variant; ignored when value is a Locale object

scope

no

String

Scope of the configuration variable: page (default), request, session or application

fmt:setLocale stores the locale in the configuration variable of the requested scope and thereby switches off browser-based selection. The specification asks for it to be declared at the beginning of the page, before any other i18n-capable action, because the actions that run earlier have already chosen their locale.

A fixed locale, setlocale.jsp
<fmt:setLocale value="es"/>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<p><fmt:message key="shelf.title"/> -- <c:out value="${pageContext.response.locale}"/></p>
<p>Mi estantería -- es</p>        <!-- Accept-Language: en, still Spanish -->

The fixed locale overrides the browser: the request with Accept-Language: en was answered in Spanish with Content-Language: es. Further observed results on Tomcat 11.0.26:

Table 8. Observed behaviour of fmt:setLocale
Input Result

value="es-ES" with scope="session"

The locale es_ES and Content-Language: es-ES; the next request of the same session, with no fmt:setLocale, was still in es_ES (the session scope persisted it)

value="es" with variant="ES"

pageContext.response.locale printed es__ES, a locale with an empty country and the variant ES

value=""

The JVM default locale, en_US, with Content-Language: en-US

value="xx_YY_ZZZ_bad"

Accepted without any error: the response locale printed XX_YY_ZZZ_BAD and Content-Language: xx

The last row is the reason to validate a locale before it reaches the tag. fmt:setLocale value="${param.lang}" accepts anything the visitor sends, and the response locale (and the Content-Language header) follow it. The safe design is a small allowlist of the application’s own languages in a servlet or filter.

A language switcher with an allowlist and Config

A language selector should not be a request parameter read by the tag. The pattern that scales is a servlet that validates the choice and stores it in the session with the Config class, after which every page of the session uses it. Config.set(session, Config.FMT_LOCALE, locale) is the Java equivalent of fmt:setLocale scope="session".

A servlet that validates and stores the locale, LanguageServlet
@WebServlet("/language")
public class LanguageServlet extends HttpServlet {
    private static final Set<String> SUPPORTED = Set.of("en", "es");

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String tag = req.getParameter("lang");
        if (tag != null && SUPPORTED.contains(tag)) {
            Config.set(req.getSession(), Config.FMT_LOCALE, Locale.forLanguageTag(tag));
        }
        resp.sendRedirect(req.getContextPath() + "/data/shelf");
    }
}
The page with the two buttons, shelf.jsp
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<h1><fmt:message key="shelf.title"/></h1>
<p><fmt:message key="shelf.count"><fmt:param value="${books.size()}"/></fmt:message></p>
<form method="post" action="${pageContext.request.contextPath}/language">
  <button name="lang" value="en">English</button>
  <button name="lang" value="es">Español</button>
</form>

The sequence below was run with a cookie jar so that the session persisted, and with Accept-Language: en on every GET:

GET  /data/shelf                  ->  <h1>My Bookshelf</h1>
POST /language  lang=es           ->  302, Location: /g5jstli18n/data/shelf
GET  /data/shelf                  ->  Content-Language: es   <h1>Mi estantería</h1>
POST /language  lang=xx           ->  302 (ignored, not in the allowlist)
GET  /data/shelf                  ->  <h1>Mi estantería</h1>

The switch is a POST because it changes state of the session, and the redirect after it follows the Post/Redirect/Get pattern. A cookie that remembers the choice across sessions is a separate decision (it is a preference cookie, with the privacy considerations of any cookie) and is not part of the specification.

The Bundle Lookup and Fallback Algorithm

Given a basename and the ordered preferred locales, the specification defines the resource bundle for a localization context in two parts.

The lookup of one locale uses ResourceBundle.getBundle(basename, locale) but only its first level: the candidates are basename_language_country_variant, basename_language_country and basename_language. The JDK’s later steps (the JVM default locale, then the root bundle) are explicitly removed so that other locales can be considered first.

The determination of the bundle then runs these steps and stops at the first success:

  1. For each preferred locale, in order, do the lookup. The first match wins and the context stores that locale and bundle.

  2. If none matched, do the lookup for the locale in the jakarta.servlet.jsp.jstl.fmt.fallbackLocale setting.

  3. If still nothing, load the root bundle (basename alone). If it exists it is used and the context has no locale.

  4. Otherwise the context has neither bundle nor locale and the action must take corrective action (fmt:message prints ???key???).

flowchart TD start["preferred locales, in order"] --> loop{"next preferred locale"} loop -- "lookup finds basename_lang[_country[_variant]]" --> done["use it: context = bundle and that locale"] loop -- "none left" --> fb{"fallbackLocale set and found?"} fb -- yes --> done2["use it: context = bundle and the fallback locale"] fb -- no --> root{"root bundle basename exists?"} root -- yes --> done3["use the root bundle: context has no locale"] root -- no --> none["context without a bundle: fmt:message prints ???key???"]

Two consequences are stated in the specification and are easy to get wrong.

  • A language match beats an exact match further down the list. With the browser preferences en then fr_CA and the bundles Messages_en and Messages_fr_CA, the bundle used is Messages_en, because the lookup for en succeeds before fr_CA is tried.

  • A bundle for a language and country (Resources_fr_CA) should be backed by one for the language alone (Resources_fr). A visitor whose browser asks only for fr finds nothing in Resources_fr_CA: the lookup for the locale fr does not look at fr_CA.

The algorithm on Tomcat 11, observed

To see the algorithm work, four families of bundles were created. Plain has Plain_en and Plain_es and no root. Region has Region_en and Region_fr_CA. Canada has Canada_en and Canada_fr_CA. Each is used by a page that prints one greeting and the response locale:

<fmt:bundle basename="org.example.bookshelf.i18n.Plain"><fmt:message key="greeting"/> | ${pageContext.response.locale}</fmt:bundle>
Table 9. Observed results (Tomcat 11.0.26, JVM default locale en_US)
Bundles and request Result Reading

Region, Accept-Language: en, fr-CA

Hello (en) | en

The language match beats the later exact match, as in the specification

Region, Accept-Language: fr-CA, en

Bonjour (fr_CA) | fr_CA

The exact match first

Plain, Accept-Language: fr,es

Hola desde el paquete en español | es

fr fails, es matches (the second preferred locale)

Plain, Accept-Language: en-GB

Hello from the English bundle | en_GB

The lookup for en_GB finds Plain_en; the context locale is the requested locale

Canada, Accept-Language: fr

Hello from Canada (en) | en_US

No Canada_fr; Canada_fr_CA is not used; the English bundle appears

Plain, Accept-Language: de, no root, no fallback

Hello from the English bundle | en_US

See the next paragraph

Plain, Accept-Language: de, fallbackLocale=es

Hola desde el paquete en español | es

The fallback locale applies

Plain, no Accept-Language, fallbackLocale=es

Hola desde el paquete en español | es

An empty preference list goes straight to the fallback locale

Messages (root exists), Accept-Language: en-GB

My Bookshelf | en_US

The root bundle, used because Messages_en_GB and Messages_en do not exist

The last-resort steps carry a surprise on the 3.0.1 implementation. For Plain with Accept-Language: de, there is no German bundle, no fallback locale and no root bundle, so the specification says the result is a context without a bundle and ???greeting???. The page printed the English greeting instead. Inspecting the bytecode of the implementation’s BundleSupport class shows that its last step is ResourceBundle.getBundle(basename, <empty locale>, contextClassLoader), and the JDK’s own algorithm, when the root bundle is missing, falls back to the JVM default locale (en_US), which finds Plain_en. The specification promises that the default locale of the container’s JVM is never considered "because this would result in a non-portable behavior"; this implementation lets it in through the last step. The practical guidance is the one the specification gives: always define a fallback locale (or a root bundle) so that the outcome never depends on the JVM that happens to run the application.

Configuration Settings

Three settings concern the i18n actions. Each is both a context-parameter name and a configuration variable name, and each has a constant in jakarta.servlet.jsp.jstl.core.Config. A scoped configuration variable overrides the context parameter for the scope where it is set.

Table 10. The i18n configuration settings (Tags 3.0, section 8.11)
Name (web.xml and variable) Config constant Type Set by Used by

jakarta.servlet.jsp.jstl.fmt.locale

Config.FMT_LOCALE

String or Locale

fmt:setLocale

The i18n actions and the four formatting actions

jakarta.servlet.jsp.jstl.fmt.fallbackLocale

Config.FMT_FALLBACK_LOCALE

String or Locale

Nothing in the tag library; use web.xml or Config.set

The same actions

jakarta.servlet.jsp.jstl.fmt.localizationContext

Config.FMT_LOCALIZATION_CONTEXT

String (basename) or LocalizationContext

fmt:setBundle without var

fmt:message and the formatting actions

Two more names appear in this area. jakarta.servlet.jsp.jstl.fmt.timeZone belongs to the formatting page. The scoped variable jakarta.servlet.jsp.jstl.fmt.request.charset is set by the i18n actions in session scope and read by fmt:requestEncoding (next sections).

Only strings can be given in web.xml. For a configuration variable the type is checked with the conversion rules of the Expression Language, and a failure raises a JspException at run time. The specification’s reason for the Config class is a naming trick: all scopes of a PageContext behave as one namespace, so setting a plain attribute in one scope would override it in all, whereas Config mangles the name per scope. This leads to a pitfall that was tested:

A configuration variable set with c:set, cset.jsp
<c:set var="jakarta.servlet.jsp.jstl.fmt.locale" value="es" scope="request"/>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<p><fmt:message key="shelf.title"/></p>

With Accept-Language: en-GB the page answered <p>My Bookshelf</p>, so the plain attribute had no effect on the locale choice, while the same request with Accept-Language: es answered in Spanish from the browser preference alone. Use fmt:setLocale or Config.set(...), which write the name the library actually reads.

The Java Side: LocalizationContext, LocaleSupport and Config

The library exposes three classes for code that is not a JSTL tag.

Table 11. The i18n Java API of Jakarta Tags 3.0
Class Use

jakarta.servlet.jsp.jstl.fmt.LocalizationContext

The pair of getResourceBundle() and getLocale(); the type of fmt:setBundle var and of the bundle attribute

jakarta.servlet.jsp.jstl.fmt.LocaleSupport

Static getLocalizedMessage(pageContext, key), (pageContext, key, basename), (pageContext, key, args) and (pageContext, key, args, basename): the same lookup as fmt:message, for tag handlers and error pages

jakarta.servlet.jsp.jstl.core.Config

get, set, find and remove of configuration variables by scope, and the constants of the settings

Because LocalizationContext is an ordinary object in a scoped variable, Expression Language reaches into it without a tag. EL 6.0 can also import a class, so the static LocaleSupport method is callable from an expression without a scriptlet.

EL access to the context and a static lookup, el.jsp
<%@ page import="jakarta.servlet.jsp.jstl.fmt.LocaleSupport" %>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages" var="msgs"/>
<p>locale: ${msgs.locale}</p>
<p>title: ${msgs.resourceBundle['shelf.title']}</p>
<p>support: ${LocaleSupport.getLocalizedMessage(pageContext, 'shelf.title', 'org.example.bookshelf.i18n.Messages')}</p>
<!-- Accept-Language: es -->        <!-- Accept-Language: en-GB,fr -->
<p>locale: es</p>                     <p>locale: </p>
<p>title: Mi estantería</p>           <p>title: My Bookshelf</p>
<p>support: Mi estantería</p>         <p>support: My Bookshelf</p>

For English with a regional preference the context has no locale (the root bundle was used), so msgs.locale is empty: test for it before using it. ${msgs.resourceBundle['key']} is useful where a message is needed inside an attribute of a non-JSTL tag, but it skips the MessageFormat step and the ???key??? convention (a missing key raises an exception from the bundle instead).

The Response Locale and the Request Charset

The i18n actions have a side effect that the specification spells out: an action that establishes a localization context sets the locale of the response by calling ServletResponse.setLocale() with the context’s locale (unless the context has no locale). fmt:setLocale always does it; fmt:bundle, fmt:setBundle, a fmt:message that establishes a context and the formatting actions that choose a formatting locale do it too. The results of the tests above show it in the Content-Language header: es for Spanish, en-GB for Accept-Language: en-GB.

The Servlet rule behind it is that setLocale also sets the response character encoding when no encoding has been set explicitly by setContentType with a charset or by setCharacterEncoding; on a stock Tomcat 11 the two response-character-encoding elements of conf/web.xml already say UTF-8, and the pages above also declare charset=UTF-8, so the charset stayed UTF-8 in every response. Two conditions apply in general: setLocale must run before the writer is obtained, which is why the page needs a large enough buffer, and an explicit charset always wins over the locale’s. How a locale maps to a charset, and what to declare so that the result never depends on the container, is on the Character Encoding page.

After setting the response locale, an action stores the charset of the response in the scoped variable jakarta.servlet.jsp.jstl.fmt.request.charset in session scope, if a session exists and is valid. That variable exists for the next action.

fmt:requestEncoding

A browser encodes the values of a form with the encoding of the page that contained the form, but most browsers do not send a charset in the request’s Content-Type, so the container has to guess how to decode the parameters. The guess is ISO-8859-1 unless the container or application says otherwise. fmt:requestEncoding sets the request character encoding by calling setCharacterEncoding and must run before any parameter is read, "either explicitly or through the use of an Expression Language expression".

Table 12. Attribute of fmt:requestEncoding
Attribute Dynamic Type Meaning

value

yes

String

The charset name to use for the parameters. If absent, the charset of the request’s Content-Type, else the session variable jakarta.servlet.jsp.jstl.fmt.request.charset, else ISO-8859-1

A form page and its handlers, form.jsp, search.jsp and search2.jsp
<%-- form.jsp: the page that makes the charset variable available --%>
<fmt:setBundle basename="org.example.bookshelf.i18n.Messages"/>
<form method="post" action="${pageContext.request.contextPath}/data/search2">
  <label><fmt:message key="form.label"/> <input name="q"></label>
  <button><fmt:message key="form.search"/></button>
</form>
<p>charset var: ${sessionScope['jakarta.servlet.jsp.jstl.fmt.request.charset']}</p>

<%-- search.jsp: a fixed value --%>
<fmt:requestEncoding value="ISO-8859-1"/>
<p>q=<c:out value="${param.q}"/> length=${param.q.length()}</p>

<%-- search2.jsp: no value, so the Content-Type charset, then the session variable --%>
<fmt:requestEncoding/>
<p>q=<c:out value="${param.q}"/> length=${param.q.length()}</p>
Table 13. Observed results on stock Tomcat 11.0.26 (conf/web.xml sets request-character-encoding to UTF-8)
Request Result

GET form.jsp after the page produced a fmt:message in the session

charset var: UTF-8 (the response charset was stored in the session)

search2, body q=ni%C3%B1o (UTF-8 bytes), no charset in Content-Type, session with the variable

q=niño length=4

search, body q=ni%F1o (ISO-8859-1 bytes), value="ISO-8859-1"

q=niño length=4

search, body q=ni%C3%B1o, value="ISO-8859-1"

q=niño length=5 (the fixed value decodes the UTF-8 bytes as Latin-1)

search, body q=ni%C3%B1o, Content-Type: ...; charset=UTF-8, value="ISO-8859-1"

q=niño length=5: the value attribute overrides the charset of the request

search2, body q=ni%F1o, no charset (invalid UTF-8)

HTTP 400, InvalidParameterException: Character decoding failed. Parameter [q] with value [ni?o] has been ignored

search2, body q=ni%F1o, Content-Type: ...; charset=ISO-8859-1

q=niño length=4

Three facts follow. A fixed value is a promise about the form’s encoding and overrides what the client declares, so it must match the page that built the form. The no-value form is meant for applications whose pages are localized dynamically, where the form’s charset varies with the locale: it reads the charset the i18n actions saved in the session when they served the form. And on Tomcat 10.1 and 11 the container already decodes parameters as UTF-8 when nothing else is said (the stock conf/web.xml), which makes the action unnecessary for an all-UTF-8 application and turns an ISO-8859-1 client into an HTTP 400 instead of silent garbage; declaring <request-character-encoding>UTF-8 in the application’s own web.xml is the container-independent equivalent. The full ordering of encodings is on the Character Encoding page.

Security: fmt:message Does Not Escape

Neither fmt:message nor fmt:param escape their output. The text of the bundle is trusted (the application’s own), but a parameter that comes from the request or the database is not, and it is written into the page as markup. A message with the visitor’s input as a parameter is therefore a cross-site scripting hole unless the parameter is escaped.

Three ways to pass a request value as a parameter, escape.jsp with ?t=<script>alert(1)</script>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<p>1: <fmt:message key="shelf.summary"><fmt:param value="${param.t}"/><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>2: <fmt:message key="shelf.summary"><fmt:param value="${fn:escapeXml(param.t)}"/><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>3: <fmt:message key="shelf.summary"><fmt:param><c:out value="${param.t}"/></fmt:param><fmt:param value="x"/><fmt:param value="y"/></fmt:message></p>
<p>4: <fmt:message key="${param.k}"/></p>
<p>1: <script>alert(1)</script> was written by x in y</p>
<p>2: &lt;script&gt;alert(1)&lt;/script&gt; was written by x in y</p>
<p>3: &lt;script&gt;alert(1)&lt;/script&gt; was written by x in y</p>
<p>4: Search</p>

Line 1 is the vulnerability. Lines 2 and 3 fix it, with fn:escapeXml (a function, available since JSTL 1.1) or with a c:out in the fmt:param body. The rule to apply uniformly is to escape every parameter that is not a constant, and to escape the whole message when the bundle itself contains no markup and the message is used as an attribute value or text. If a translated message legitimately contains markup, such as a link, keep that markup in the bundle and escape only the parameters.

Line 4 is a different problem: the key came from the request, so a visitor can read any message of the bundle (form.search was returned), which can reveal internal or unreleased text. Keys should be literals or come from the application, never from a request parameter. The same applies to the basename of fmt:bundle and to a locale taken from a parameter (see the table of fmt:setLocale).

Bundle files are application resources. They should live inside the classpath (WEB-INF/classes or a library), not in a directory served by the container, and translations received from third parties are reviewed like code, because a translation can contain markup and the tag does not escape it.

The Same Idea in Spring and Struts

fmt:message is not the only way to reach a bundle from a JSP. Spring MVC resolves the locale with a LocaleResolver (accept-header, cookie or session, plus an interceptor that reads a request parameter) and reads messages with a MessageSource; its JSP tag library offers spring:message for the same job as fmt:message, and the two libraries can coexist on a page. In Struts the bundles are resolved by the framework with a different search order, and the Localization and i18n page of the Struts part describes it. The point for a JSP author is that the concept (locale chosen per request, text by key, parameters by position) is the same in all of them, while the bundle search algorithm and the way a locale is selected are specific to each.

Pitfalls and Error Names

Table 14. i18n pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

???key??? in the page (observed)

The key is missing, or there is no bundle: no fmt:setBundle, fmt:bundle or context-parameter, or a mistyped basename

Check the basename (dots, not slashes, no .properties), the classpath location and the key; grep the output for ???

?????? (observed)

The key attribute evaluated to null or an empty string

Check the expression that supplies the key

Spanish text is garbled or shows ñ

A .properties file saved in an encoding other than UTF-8, or the page served with the wrong charset

Save bundles as UTF-8 (Java 9 and later read them as UTF-8, falling back to ISO-8859-1 only for invalid sequences); declare charset=UTF-8

The year prints as 1,965 (observed)

MessageFormat formats numbers with grouping

{2,number,#} in the pattern, or pass a String

Dont panic about {0} (observed)

A single apostrophe in a message used with fmt:param

Double the apostrophe in messages with parameters

Don''t panic (observed)

A doubled apostrophe in a message used without fmt:param

Do not double it where MessageFormat is not used, or always pass a parameter

<script> from the request is executed (observed)

fmt:param and fmt:message do not escape

fn:escapeXml or c:out on every non-constant parameter

The page ignores the visitor’s language after fmt:setLocale

A fixed locale disables browser-based selection

Set it only from a user choice; otherwise let the browser decide

The locale does not change for the first part of the page

fmt:setLocale placed after other i18n actions

Put it first (or in a servlet or filter with Config.set)

c:set var="jakarta.servlet.jsp.jstl.fmt.locale" does nothing (observed)

The library reads a scope-mangled name written by Config

fmt:setLocale or Config.set

English text appears for a visitor who asked for German (observed)

No root bundle and no fallback locale, so the JDK’s default-locale step of getBundle finds the English bundle

Define jakarta.servlet.jsp.jstl.fmt.fallbackLocale or a root bundle

The French-Canadian bundle is never used (observed)

Only _fr_CA exists and the visitor asks for fr

Provide the language bundle (_fr) behind the country one

Illegal scope attribute without var in "fmt:message" tag. (observed)

scope without var

Add var or drop scope

<param> outside <message> (observed)

fmt:param is not a child of fmt:message

Nest it

Unable to convert string [...] to class [jakarta.servlet.jsp.jstl.fmt.LocalizationContext] for attribute [bundle] (observed)

A basename string passed to the bundle attribute

Create the context with fmt:setBundle var and pass the variable

JasperException: The absolute uri: [...] cannot be resolved in either web.xml or the jar files deployed with this application (observed)

A wrong or old taglib URI such as http://xmlns.jcp.org/jsp/jstl/fmt, or the JSTL implementation is missing from WEB-INF/lib

uri="jakarta.tags.fmt" and the JSTL implementation jar

HTTP 400 Character decoding failed. Parameter [q] ... (observed)

A form posted in ISO-8859-1 to a container that decodes UTF-8

Serve the form in UTF-8, or fmt:requestEncoding value="..." that matches the form

fmt:requestEncoding has no effect

A parameter was read first (a filter, an include, an EL expression)

Place it first or use request-character-encoding in web.xml

Edits to a .properties file are not seen

ResourceBundle caches bundles; the specification says that changing the available bundles while the page runs is implementation-specific

Redeploy or restart; do not rely on live reload in production

A bundle added to WEB-INF/classes is not found

The file is not on the classpath of the application, or the basename does not match the package

Build output must contain org/example/bookshelf/i18n/Messages.properties for the basename org.example.bookshelf.i18n.Messages

Content-Language appears without the application asking for it (observed)

The i18n actions set the response locale as a side effect

Intentional; make sure the declared locale is accurate

What Changed Recently

The i18n actions are stable: chapter 8 of Tags 3.0 describes the same actions as JSTL 1.0. What changed is around them.

Table 15. Changes that affect the i18n actions
Version Change Effect for JSP authors

Jakarta Tags 2.0 (Jakarta EE 9)

The packages moved from javax.servlet.jsp.jstl to jakarta.servlet.jsp.jstl

Imports of LocaleSupport, Config and LocalizationContext change; the library and fmt: actions are the same

Jakarta Tags 3.0 (Jakarta EE 10, Java 11)

Tag URIs renamed to jakarta.tags.*, JPMS module info added, dependency on XML Binding removed from the API

uri="jakarta.tags.fmt"; the older URIs still resolve in the 3.0.1 implementation (observed)

Java 9 (JEP 226)

PropertyResourceBundle reads .properties files as UTF-8, falling back to ISO-8859-1

Bundles can contain accented characters directly; native2ascii and ñ escapes are no longer necessary

Tomcat 10.1 and 11

conf/web.xml sets request and response character encoding to UTF-8

UTF-8 forms work without fmt:requestEncoding; a Latin-1 form posted without a charset now gets HTTP 400 (observed)

Jakarta EE 11 (Pages 4.0, Servlet 6.1)

Tags stays at 3.0

Nothing changes in fmt: usage

Jakarta Tags 3.1 (Jakarta EE 12, under development)

The Jakarta site lists the specification as under development; Tags 3.1.0-M2 is the milestone API on Maven Central

Per the section’s baseline, java.time support in the formatting actions is planned; the i18n actions are not announced to change. Treat it as in progress, not as a feature to rely on

Where the Books Are Outdated

JSTL in Action (Manning, 2002, JSTL 1.0 on JSP 1.2) devotes a chapter to formatting and internationalization, and its chapters on configuration and on control and performance touch the topic again. It is listed in the section bibliography. The concepts it teaches (locale, bundle, basename, the localization context, fmt:message with parameters, the fallback locale, fmt:requestEncoding, the Config settings) are the same today; the platform around them is not.

Table 16. JSP 1.2 and JSTL 1.0-era i18n material and its modern equivalent
The book’s era Today (Tags 3.0, Pages 4.0)

javax.servlet.jsp.jstl.fmt.* configuration names and classes

jakarta.servlet.jsp.jstl.fmt.* (EE 9 and later)

http://java.sun.com/jstl/fmt and the separate fmt_rt twin library for request-time attribute values

jakarta.tags.fmt; the container evaluates the Expression Language in every attribute, so there is one library and no _rt twin

Attributes that accept only literal strings or JSTL-evaluated EL, with scriptlet expressions in the _rt twin

EL in every dynamic attribute; no scriptlets needed (and disabled in modern configuration with scripting-invalid)

Setting the configuration names as plain scoped attributes of the page, a tempting shortcut

fmt:setLocale, fmt:setBundle, or Config.set; the plain-name attribute has no effect with the current implementation (observed)

.properties bundles written with \uXXXX escapes and processed with native2ascii

UTF-8 files read natively since Java 9

MessageFormat as the only tool for plurals and gender

Still the tool behind fmt:param; richer rules need ICU4J’s MessageFormat or a framework

request.getParameter and new String(bytes, "ISO-8859-1") re-decoding to fix form encoding

fmt:requestEncoding, request-character-encoding in web.xml, or a filter, so the container decodes correctly the first time

Struts 1’s MessageResources and <bean:message> or Struts 2’s getText() and s:text as the way to localize

Still valid inside those frameworks; the Localization and i18n page of the Struts part covers them

Locale chosen by a servlet and passed to the page as a request attribute

Same idea, with Config.set(session, Config.FMT_LOCALE, locale) so that every fmt: action follows it

The EE tutorial’s JSP i18n material (J2EE 1.4, Java EE 5)

Archived; the current tutorial’s i18n chapter is written for Jakarta Faces

References