Messages and Internationalization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later. Faces 4.0 reads the same on every point below as far as the specification text goes (nothing in the 4.1 changes touches messages or locales), but the examples were not run on 4.0. Faces 5.0 is still a milestone (5.0.0-M9 on 2026-09-18) and is described only as upcoming. Every example and every statement about the output was run in a scratch Bookshelf WAR served by Tomcat 11.0.26 with Weld 6.0.4.Final (Java 26 as the runtime), once with Mojarra and once with MyFaces, using curl with different Accept-Language headers (checked on 2026-10-08). Where the two implementations disagree, the text says which one does what.

A web application talks to people in two ways: it reports what happened (a field is missing, a review was saved), and it presents its text, numbers and dates in the reader’s language. Faces covers both with the same machinery. A FacesMessage carries a report from the Java code to the page; a ResourceBundle carries the text of the page itself; and one active locale, stored on the view, decides which language and which number and date conventions both use. This page walks through each piece with the Bookshelf review form in English and Spanish.

Where Messages and Locales Sit in the Lifecycle

Nothing here has a phase of its own; it hangs off the phases described on the request processing lifecycle page:

  • Restore View (or the initial build) fixes the view’s locale. The ViewHandler picks it when it creates the view, from the browser’s preferences and the locales the application supports, and the locale attribute of f:view can override it.

  • Process Validations is where most messages are born. A converter that cannot convert, a required input that is empty or a validator that rejects the value queues a FacesMessage on the FacesContext, and the lifecycle then jumps straight to Render Response without touching the model.

  • Invoke Application is where your action method adds its own messages (Review saved, Already reviewed).

  • Render Response turns the queued messages into markup (h:message, h:messages), pulls view text out of the resource bundle for the view’s locale, and formats numbers and dates with that same locale.

FacesMessage: Severity, Summary and Detail

A jakarta.faces.application.FacesMessage has three parts: a severity, a summary (the short text) and a detail (the longer explanation). The severity is one of four constants in ascending order: SEVERITY_INFO, SEVERITY_WARN, SEVERITY_ERROR and SEVERITY_FATAL. Constructing a message without a severity gives INFO, and getDetail() returns the summary when no detail was set, so a message built with null as the detail still has something to show in places that display the detail.

Since Faces 4.1 a message can be compared. FacesMessage gained equals(), hashCode() and toString() (issue 1823 in the specification’s change log; the Javadoc marks the three methods Since: 4.1), and FacesMessage.VALUES and FacesMessage.VALUES_MAP became generic (issue 1789): a List<FacesMessage.Severity> in ascending order and a Map<String, FacesMessage.Severity> keyed by the severity names. The first is useful to code that queues messages: it can look for an equal message before adding another one, and a Set<FacesMessage> now behaves as a set.

A plain Java program that compares messages (no server needed)
import jakarta.faces.application.FacesMessage;
import java.util.*;
public class F { public static void main(String[] a){
 FacesMessage x=new FacesMessage(FacesMessage.SEVERITY_ERROR,"Bad","detail");
 FacesMessage y=new FacesMessage(FacesMessage.SEVERITY_ERROR,"Bad","detail");
 FacesMessage z=new FacesMessage(FacesMessage.SEVERITY_WARN,"Bad","detail");
 System.out.println("equals same fields: "+x.equals(y)+" hash equal: "+(x.hashCode()==y.hashCode()));
 System.out.println("equals other severity: "+x.equals(z));
 System.out.println("toString: "+x);
 System.out.println("set size: "+new HashSet<>(List.of(x,y,z)).size());
 List<FacesMessage.Severity> v=FacesMessage.VALUES; Map<String,FacesMessage.Severity> m=FacesMessage.VALUES_MAP;
 System.out.println("VALUES: "+v+" MAP keys: "+m.keySet());
 FacesMessage.Severity s=m.get("ERROR"); System.out.println("typed lookup: "+s);
}}

Compiled against the Mojarra 4.1.17 jar and, separately, the MyFaces API 4.1.4 jar, the program printed on both equals same fields: true hash equal: true, equals other severity: false and set size: 2 (three messages, two of them equal), and typed lookup for "ERROR" returned the severity (the first attempt used the key "Error" and got null: the keys are the upper-case names). VALUES printed [INFO 0, WARN 1, ERROR 2, FATAL 3] on Mojarra and [INFO, WARN, ERROR, FATAL] on MyFaces. The one visible difference is toString(): FacesMessage[severity='ERROR 2', summary='Bad', detail='detail'] on Mojarra and FacesMessage[severity='ERROR', summary='Bad', detail='detail'] on MyFaces, so do not parse the string. What the equality is compared on beyond severity, summary and detail was not tested, and nothing here shows Faces itself dropping duplicates from the queue; the removal of a duplicate is code you write. Faces 5.0 is planned to add a fifth severity, SUCCESS (issue 1592, listed on the 5.0 specification page); it is not in 4.1, so keep using INFO for a success text and style it yourself (see the versions page).

The client id is not part of the message; it is the first argument of FacesContext.addMessage(String, FacesMessage). A message added with a client id ("review:title") belongs to that component and is shown by an h:message whose for names it. A message added with a null client id is a global message: it belongs to the page and is shown by an h:messages. The Bookshelf review bean uses both:

ReviewBean.java
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.annotation.ManagedProperty;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.text.MessageFormat;
import java.util.ResourceBundle;

@Named
@RequestScoped
public class ReviewBean {

    @Inject
    private FacesContext context;

    @Inject
    @ManagedProperty("#{msg}")
    private ResourceBundle msg;

    private String title;
    private Integer rating;
    private String isbn;
    private String text;

    public String save() {
        if ("Dune".equals(title)) {
            // a message that belongs to one component
            String summary = msg.getString("review.duplicate");
            context.addMessage("review:title",
                    new FacesMessage(FacesMessage.SEVERITY_WARN, summary, null));
            return null;
        }
        // a global message: no client id
        String summary = MessageFormat.format(msg.getString("review.saved"), "Ana", title);
        context.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, summary, null));
        return null;
    }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public Integer getRating() { return rating; }
    public void setRating(Integer rating) { this.rating = rating; }
    public String getIsbn() { return isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }
    public String getText() { return text; }
    public void setText(String text) { this.text = text; }
}

Three details of that bean are worth a sentence each:

  • @Inject FacesContext works because Faces provides a CDI producer for the current context; the older FacesContext.getCurrentInstance() call does the same and is still what you use outside a CDI bean.

  • @Inject @ManagedProperty("#{msg}") injects the msg resource bundle declared in faces-config.xml (see below). jakarta.faces.annotation.ManagedProperty is a CDI qualifier that evaluates an EL expression when the bean is injected, so use it in request-scoped beans. It is not the removed javax.faces.bean.ManagedProperty of the books. Beans that live longer ask the application for the bundle on each use instead, as shown next.

  • MessageFormat.format(...) is needed because ResourceBundle.getString() returns the raw pattern. Substitution of {0} and {1} is done by you in Java and by h:outputFormat in a view.

A bean that lives longer than a request, or code that has no injected bundle, asks the application, which returns the bundle for the locale of the current view:

ResourceBundle bundle = context.getApplication().getResourceBundle(context, "msg");
String summary = bundle.getString("review.duplicate");

Showing Messages: h:message and h:messages

h:message renders one message for one component, the first message queued for the client id in its for attribute. h:messages renders all queued messages, as an HTML list by default or as a table with layout="table". Their defaults differ on purpose: h:message shows the detail (showDetail defaults to true, showSummary to false), because it sits beside one field and has room for the explanation; h:messages shows the summary (showSummary defaults to true, showDetail to false), because it lists many.

The Bookshelf layout (a template, see the templating page) shows global messages once, so every page gets them:

WEB-INF/templates/layout.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:f="jakarta.faces.core"
      xmlns:h="jakarta.faces.html"
      xmlns:ui="jakarta.faces.facelets">
<f:view locale="#{localeBean.locale}">
  <h:head>
    <title>#{msg['app.name']}</title>
  </h:head>
  <h:body>
    <header>
      <h:form id="lang">
        #{msg['nav.language']}:
        <h:commandLink id="en" value="English" action="#{localeBean.switchTo('en')}"/> |
        <h:commandLink id="es" value="Español" action="#{localeBean.switchTo('es')}"/>
      </h:form>
    </header>
    <main>
      <h:messages id="global" globalOnly="true"
                  infoClass="info" warnClass="warn" errorClass="error" fatalClass="fatal"/>
      <ui:insert name="content"/>
    </main>
  </h:body>
</f:view>
</html>

And a review page puts one h:message beside each field:

review.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:f="jakarta.faces.core"
      xmlns:h="jakarta.faces.html"
      xmlns:ui="jakarta.faces.facelets">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="content">
    <h1>#{msg['review.title']}</h1>
    <h:form id="review">
      <h:outputLabel for="title" value="#{msg['review.field.title']}"/>
      <h:inputText id="title" value="#{reviewBean.title}" required="true"
                   label="#{msg['review.field.title']}">
        <f:validateLength minimum="3"/>
      </h:inputText>
      <h:message for="title" errorClass="error" warnClass="warn"/>

      <h:outputLabel for="rating" value="#{msg['review.field.rating']}"/>
      <h:inputText id="rating" value="#{reviewBean.rating}"
                   label="#{msg['review.field.rating']}"/>
      <h:message for="rating" errorClass="error"/>

      <h:outputLabel for="isbn" value="ISBN"/>
      <h:inputText id="isbn" value="#{reviewBean.isbn}">
        <f:validator validatorId="bookshelf.isbnLength"/>
      </h:inputText>
      <h:message for="isbn" errorClass="error"/>

      <h:outputLabel for="text" value="#{msg['review.field.text']}"/>
      <h:inputTextarea id="text" value="#{reviewBean.text}" required="true"
                       requiredMessage="#{msg['review.text.required']}"/>
      <h:message for="text" errorClass="error"/>

      <h:commandButton id="send" value="#{msg['review.submit']}" action="#{reviewBean.save}"/>
    </h:form>
  </ui:define>
</ui:composition>
</html>

Submitting that form with a two-letter title, the text abc as the rating and an empty review produced, in Spanish (Accept-Language: es), the following text beside the fields on both Mojarra and MyFaces:

Field Text shown

Título

Título: al menos 3 caracteres, por favor.

Valoración

Escribe solo dígitos, por ejemplo 9346.

ISBN

El ISBN debe tener 13 dígitos, pero "123" tiene 3.

Reseña

Escribe algo en tu reseña.

(The example number inside the rating message differs between implementations; see the comparison table below.)

The attributes both tags share, from the VDL documentation:

Attribute Meaning

for

Required on h:message: the client id of the component whose message to show. On h:messages it is optional and narrows the list to that component.

globalOnly

h:messages only. When true, show only messages with no client id. Default false. Mutually exclusive with for; for wins if both are given.

showSummary, showDetail

Whether to render the summary and the detail text. The defaults differ between the two tags, as described above.

layout

h:messages only. list (the default, nested ul and li) or table (nested table, tr and td).

infoClass, warnClass, errorClass, fatalClass

CSS class applied per severity. infoStyle, warnStyle, errorStyle and fatalStyle do the same with inline style. A per-severity class takes the place of styleClass for that message.

tooltip

When true and both summary and detail are shown, the summary becomes the title of the rendered span, so it appears as a tooltip.

style, styleClass, title, dir, lang, id, rendered, binding

The usual generic attributes.

A few behaviours you will meet quickly:

  • Messages live for one request. They are queued on the FacesContext of the current request. When the response is a redirect, they are lost unless you ask the flash to keep them. h:messages with the id global in the layout did not show the message after a Post/Redirect/Get until the action called context.getExternalContext().getFlash().setKeepMessages(true); with it, the message appeared on the page the browser was redirected to, on both implementations. Without the call the redirected page showed nothing.

    Keeping a message across a redirect
    public String saveAndRedirect() {
        context.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO,
                msg.getString("review.saved"), null));
        context.getExternalContext().getFlash().setKeepMessages(true);
        return "index?faces-redirect=true";
    }
  • A message nobody displays is not lost silently while you develop. The VDL documentation says that when a form has no h:messages and the project stage is Development, the runtime adds one for debugging. In the scratch run an element with the id jakarta_faces_developmentstage_messages was appended to every page, empty when there was nothing unhandled. Do not style or rely on it; put your own h:messages where users should look.

  • In an Ajax request the message component must be in the render list. A message added during an Ajax postback only reaches the screen if the h:messages (or h:message) is re-rendered. The snippet below renders the form and, by absolute id, the global list from the layout. The Ajax event model itself is covered on the Ajax page of this section.

    An Ajax submit that refreshes field and global messages
    <h:form id="review">
      <h:inputText id="title" value="#{reviewBean.title}" required="true"
                   label="#{msg['review.field.title']}"/>
      <h:message id="titleMsg" for="title" errorClass="error"/>
      <h:commandButton id="send" value="#{msg['review.submit']}" action="#{reviewBean.save}">
        <f:ajax execute="@form" render="@form :global"/>
      </h:commandButton>
    </h:form>

    In the scratch run, with Accept-Language: es, an empty title returned a partial response whose form update carried the Spanish detail text under the field, and a valid title returned an update of the global list carrying the saved message.

  • Other component libraries have their own renderers for the same queue. PrimeFaces provides its own message components (p:messages and p:growl, among others) that read the same FacesMessage list; they are covered with PrimeFaces. The OmniFaces utility classes wrap the boilerplate of creating and adding messages.

The Messages Faces Itself Raises

The messages in the table above, such as Título: al menos 3 caracteres, were not written in Java by the Bookshelf. They are standard messages: when a converter, a validator or the required check fails, the implementation creates a FacesMessage from a message key. The specification requires every implementation to ship a resource bundle named jakarta.faces.Messages with the same keys, in several languages. Each key looks up the summary, and the same key with _detail appended looks up the detail. The values are java.text.MessageFormat patterns.

Some of the keys in the Mojarra 4.1.17 bundle (the specification lists the full set):

Key Raised by Placeholders

jakarta.faces.component.UIInput.REQUIRED

required="true" on an empty input

{0} is the label

jakarta.faces.component.UIInput.CONVERSION

a converter threw and gave no message

{0} is the label

jakarta.faces.converter.IntegerConverter.INTEGER and _detail

text that is not an integer

{0} the value, {1} an example value, {2} the label

jakarta.faces.converter.DateTimeConverter.DATE and _detail

text that is not a date

{0} the value, {1} an example, {2} the label

jakarta.faces.validator.LengthValidator.MINIMUM and MAXIMUM

f:validateLength

{0} the limit, {1} the label

jakarta.faces.validator.LongRangeValidator.NOT_IN_RANGE

f:validateLongRange

{0} the minimum, {1} the maximum, {2} the label

jakarta.faces.validator.RegexValidator.NOT_MATCHED and _detail

f:validateRegex

the detail text receives the pattern as {0}

jakarta.faces.validator.BeanValidator.MESSAGE

Bean Validation constraint violations

{0} is the text produced by the constraint

The last placeholder of the converter and validator messages is the label of the input: the value of its label attribute, or, when the attribute is absent, its client id. That is why the review form above sets label="#{msg['review.field.title']}": without it a failure starts with the bare id, for example j_idt22 or review:title, which means nothing to a reader. In the scratch run a converter on an h:outputText without a label reported the generated id j_idt22 in its message.

Three attributes on input components replace a standard message for that one component, without any bundle: requiredMessage, converterMessage and validatorMessage. The values are ordinary EL, so they come from the resource bundle and follow the view locale. The review form above uses requiredMessage="#{msg['review.text.required']}", and the empty review showed Escribe algo en tu reseña. in Spanish and Please write something in your review. in English. Note that validatorMessage replaces the message of every validator attached to the input, including Bean Validation’s, which is why it is better for a single, generic sentence and not for per-constraint texts. How a Bean Validation violation becomes a FacesMessage, how the constraint messages are localised with ValidationMessages bundles and how the label placeholder is filled are on the Bean Validation page; validatorMessage versus Bean Validation messages was not run on this page.

Overriding the Standard Messages: the Message Bundle

To change the wording of the standard messages, supply your own bundle in faces-config.xml with the message-bundle element. Faces then follows this algorithm, taken from the specification, every time it needs a message for a key:

flowchart TD A["Faces needs a message for a key such as jakarta.faces.component.UIInput.REQUIRED"] --> B{"Does the application define a message-bundle?"} B -- "yes" --> C{"Is the key in that bundle for the view locale?"} B -- "no" --> E["Look in jakarta.faces.Messages"] C -- "yes" --> D["Use it as the summary and key_detail as the detail"] C -- "no" --> E E --> F["Build the FacesMessage and substitute the placeholders with MessageFormat"] D --> F F --> G["Last placeholder = label attribute, or the client id when there is no label"]

Only the keys you override need to be present; everything else falls back to the implementation’s bundle, in the language of the view. The Bookshelf declares its bundle for view text and its bundle for standard messages side by side:

WEB-INF/faces-config.xml
<?xml version="1.0" encoding="UTF-8"?>
<faces-config 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-facesconfig_4_1.xsd"
              version="4.1">
  <application>
    <locale-config>
      <default-locale>en</default-locale>
      <supported-locale>es</supported-locale>
    </locale-config>
    <resource-bundle>
      <base-name>org.example.bookshelf.messages</base-name>
      <var>msg</var>
    </resource-bundle>
    <message-bundle>org.example.bookshelf.FacesMessages</message-bundle>
  </application>
</faces-config>
src/main/resources/org/example/bookshelf/FacesMessages.properties
jakarta.faces.component.UIInput.REQUIRED={0} is required.
jakarta.faces.component.UIInput.REQUIRED_detail=Fill in the field "{0}" before sending the form.
jakarta.faces.converter.IntegerConverter.INTEGER={2}: "{0}" is not a whole number.
jakarta.faces.converter.IntegerConverter.INTEGER_detail=Type digits only, for example {1}.
jakarta.faces.validator.LengthValidator.MINIMUM={1}: at least {0} characters, please.
src/main/resources/org/example/bookshelf/FacesMessages_es.properties
jakarta.faces.component.UIInput.REQUIRED={0} es obligatorio.
jakarta.faces.component.UIInput.REQUIRED_detail=Rellena el campo "{0}" antes de enviar el formulario.
jakarta.faces.converter.IntegerConverter.INTEGER={2}: "{0}" no es un número entero.
jakarta.faces.converter.IntegerConverter.INTEGER_detail=Escribe solo dígitos, por ejemplo {1}.
jakarta.faces.validator.LengthValidator.MINIMUM={1}: al menos {0} caracteres, por favor.
src/main/resources/org/example/bookshelf/FacesMessages_en.properties
# Intentionally empty: every key is inherited from FacesMessages.properties.
# See "The JVM default locale" below for why this file exists.

Posting the review form with an empty title in English and with the title ab and the rating abc in Spanish produced, on both implementations:

Request Message text

English, empty title

Fill in the field "Title" before sending the form. (the detail of REQUIRED)

Spanish, short title

Título: al menos 3 caracteres, por favor. (LengthValidator.MINIMUM)

Spanish, rating abc

Escribe solo dígitos, por ejemplo 9346. (the detail of INTEGER)

Two things stand out. The MINIMUM key has no _detail partner in the bundle, and the page still shows the text: FacesMessage.getDetail() falls back to the summary, so a field with an h:message (which shows the detail) reads correctly even when you only translated the summary. And the rating example was 9346 on Mojarra and 21 on MyFaces: the {1} example value is the implementation’s choice, which is one more reason not to quote it in tests.

Messages from your own validators and converters

A custom Validator or Converter reports failure by throwing ValidatorException or ConverterException carrying a FacesMessage. Build that message from the application’s resource bundle for the view’s locale, so the text follows the user’s language. The Bookshelf ISBN validator (id bookshelf.isbnLength):

IsbnLengthValidator.java
package org.example.bookshelf.web;

import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;
import java.text.MessageFormat;
import java.util.ResourceBundle;

@FacesValidator("bookshelf.isbnLength")
public class IsbnLengthValidator implements Validator<String> {

    private static final int LENGTH = 13;

    @Override
    public void validate(FacesContext context, UIComponent component, String value) {
        if (value == null || value.length() == LENGTH) {
            return;
        }
        ResourceBundle bundle = context.getApplication().getResourceBundle(context, "msg");
        String text = MessageFormat.format(bundle.getString("isbn.length"),
                LENGTH, value, value.length());
        throw new ValidatorException(new FacesMessage(FacesMessage.SEVERITY_ERROR, text, null));
    }
}

With isbn.length=ISBN must have {0} digits, but "{1}" has {2}. in the base bundle and its Spanish version in messages_es.properties, an ISBN 12345 gave ISBN must have 13 digits, but "12345" has 5. and El ISBN debe tener 13 dígitos, pero "12345" tiene 5. Validators and converters are explored on the validators and converters pages of this section. Bean Validation has its own localisation: the specification (3.5.6.4) asks Faces to supply a message interpolator that resolves the locale the Faces way, so constraint messages follow the view locale and the jakarta.faces.validator.BeanValidator.MESSAGE key wraps the result.

Resource Bundles for the Text of Your Pages

The message bundle above is only for Faces' own messages. The text of your pages (headings, labels, button captions, your own messages) goes in a second bundle, a normal java.util.ResourceBundle, and the views read it through an EL variable. There are two ways to expose it.

Declaring the bundle once: resource-bundle

The <resource-bundle> element in faces-config.xml (shown above; the file itself is introduced on the project setup page) names the bundle (base-name) and the EL variable (var). Every view and every bean in the application can then use #{msg['key']}, and the variable always resolves to the bundle for the current view’s locale. The base name is a fully qualified resource name without extension, so org.example.bookshelf.messages means org/example/bookshelf/messages.properties on the class path, with messages_es.properties as the Spanish sibling:

src/main/resources/org/example/bookshelf/messages.properties
app.name=Bookshelf
nav.language=Language
catalogue.title=Catalogue
catalogue.count={0,choice,0#No books|1#One book|1<{0} books} in the catalogue
review.title=Write a review
review.field.title=Title
review.field.rating=Rating
review.field.text=Review
review.submit=Send review
review.text.required=Please write something in your review.
review.saved=Thank you, {0}: your review of "{1}" was saved.
review.duplicate=You already reviewed this book.
isbn.length=ISBN must have {0} digits, but "{1}" has {2}.
src/main/resources/org/example/bookshelf/messages_es.properties
app.name=Estantería
nav.language=Idioma
catalogue.title=Catálogo
catalogue.count={0,choice,0#Ningún libro|1#Un libro|1<{0} libros} en el catálogo
review.title=Escribe una reseña
review.field.title=Título
review.field.rating=Valoración
review.field.text=Reseña
review.submit=Enviar reseña
review.text.required=Escribe algo en tu reseña.
review.saved=Gracias, {0}: tu reseña de "{1}" se ha guardado.
review.duplicate=Ya has reseñado este libro.
isbn.length=El ISBN debe tener {0} dígitos, pero "{1}" tiene {2}.
src/main/resources/org/example/bookshelf/messages_en.properties
# Intentionally empty: every key is inherited from messages.properties.
# See "The JVM default locale" below for why this file exists.

A view then reads the text with #{msg['catalogue.title']}. The bracket form works for every key; the dotted form #{msg.catalogue_title} works only for keys that are valid Java identifiers, which is why keys with dots, such as the ones above, are always written msg['...']. A key that does not exist renders as ???key??? and does not throw: in the scratch run #{msg['no.such.key']} printed ???no.such.key??? on both implementations, which is the behaviour the VDL documentation of f:loadBundle requires of its map and the one <resource-bundle> shows too. A missing bundle is a different matter and is treated as an error.

Loading a bundle in one view: f:loadBundle

f:loadBundle loads a bundle into a map stored as a request attribute under the name in var, for the locale of the current view. It needs no faces-config.xml entry, which makes it handy for a bundle used by a single page:

<f:view locale="en" xmlns:f="jakarta.faces.core" xmlns:h="jakarta.faces.html">
  <f:loadBundle basename="org.example.bookshelf.messages" var="lb"/>
  <h:outputText value="#{lb['nav.language']}"/>
</f:view>

The two can be compared directly:

<resource-bundle> in faces-config.xml f:loadBundle in a view

Scope of the variable

The whole application, in views and in beans

The request, in the views that contain the tag

Available from Java

Yes: Application.getResourceBundle(context, "msg") and @ManagedProperty("#{msg}")

No: only the view’s EL can see the request attribute

Must be repeated

No

In every view that needs it

Missing key

???key???

???key???, required by the VDL documentation

Missing bundle

Not exercised in the scratch run

A TagAttributeException, as the VDL documentation requires

Use it for

The application’s text

A bundle used by one page, or a library’s own bundle

Both gave identical output for the same bundle in the scratch run. Prefer <resource-bundle> for the application’s text, because one declaration serves views and beans alike.

The JVM default locale: a trap in the bundle lookup

ResourceBundle.getBundle does not go straight from messages_en to messages. When the requested locale has no bundle of its own, Java first tries the bundle for the JVM’s default locale, and only then the base bundle. In the scratch run the server JVM was started with -Duser.language=es. With only messages.properties and messages_es.properties on the class path, a request with Accept-Language: en rendered Spanish: there was no messages_en.properties, so Java fell back to messages_es (the JVM default) before reaching the English base. The same behaviour was visible on both implementations, because both go through ResourceBundle.

The fix is an English bundle that exists, even when it is empty: with messages_en.properties (a comment is enough) present, the en request found its own bundle and inherited every key from the base. The messages_en.properties and FacesMessages_en.properties files shown above exist for exactly this reason. Developers on a Spanish-locale laptop and servers in a Spanish-locale container hit this; a teammate in an English-locale JVM does not, which is why it survives code review.

Resource bundle files are UTF-8

Since Java 9, PropertyResourceBundle reads a .properties stream as UTF-8 and falls back to ISO-8859-1 only when the bytes are not valid UTF-8. The Bookshelf files above contain á, é, í, ñ and ó written directly and rendered correctly on both implementations under Java 26. The \uXXXX escapes and the native2ascii step of Java 8 and earlier, which the older books show, are no longer needed.

Parameterised and Plural Messages

Bundle values are java.text.MessageFormat patterns. In a view, h:outputFormat fills them in from nested f:param elements. The Bookshelf catalogue header needs singular and plural, which a choice format expresses in the pattern itself, so the translator controls the grammar for each language:

catalogue.xhtml
<h1>#{msg['catalogue.title']}</h1>
<p>
  <h:outputFormat value="#{msg['catalogue.count']}">
    <f:param value="#{catalogueBean.size}"/>
  </h:outputFormat>
</p>
<p>
  <h:outputText value="#{catalogueBean.published}">
    <f:convertDateTime type="localDate" dateStyle="long"/>
  </h:outputText>
</p>
<p>
  <h:outputText value="#{catalogueBean.price}">
    <f:convertNumber type="currency" currencyCode="EUR"/>
  </h:outputText>
</p>

With a catalogue of 3 books, the three counts 0, 1 and 3 rendered, in English and then Spanish:

Count English Spanish

0

No books in the catalogue

Ningún libro en el catálogo

1

One book in the catalogue

Un libro en el catálogo

3

3 books in the catalogue

3 libros en el catálogo

Four rules for these patterns:

  • The parameter must be a number for a choice or number element. <f:param value="3"/> passes the string "3" and the page failed with Cannot format given Object as a Number; <f:param value="#{3}"/> or a bean property of type int works.

  • An apostrophe in a pattern that has placeholders must be doubled. In MessageFormat a single quote starts a quoted section. With the English text Don't panic: {0} books and one parameter, Mojarra and MyFaces both rendered Dont panic: {0} books: the quote vanished and the placeholder was not replaced. Written Don''t panic: {0} books it renders correctly. This is why the standard Faces messages contain ''{0}''.

  • The doubling is only right where MessageFormat runs. #{msg['key']} printed directly, or ResourceBundle.getString() without a MessageFormat call, shows the pattern verbatim, so Don''t appears with both quotes. The two cases need two versions of the text, or you always format.

  • Dates passed as parameters use the JVM’s time zone. MessageFormat formats a Date argument itself, in the JVM default zone (the epoch showed 1:00 on a server in Spain). Format the date with f:convertDateTime, where timeZone is explicit, and put the formatted string in the message.

Mojarra and MyFaces differ on one more point. With no f:param children, the VDL documentation says the value is rendered unmodified, which is what Mojarra did (Don't panic stayed intact). MyFaces applied MessageFormat anyway and printed Dont panic. Do not depend on either: a message that contains an apostrophe should be doubled, or never sent through h:outputFormat without parameters.

Choosing the Locale

Faces keeps one active locale per view, on UIViewRoot. Everything locale-aware reads it: the resource bundle variable, converters and validators, the messages above and f:convertNumber. Declare what the application supports in faces-config.xml (the block shown earlier):

<locale-config>
  <default-locale>en</default-locale>
  <supported-locale>es</supported-locale>
</locale-config>

When a view is created, the ViewHandler calculates a locale: it compares the browser’s preferences, the Accept-Language header, with the supported locales and uses the default when none matches. The specification leaves the details to the Javadoc, so they were observed. Both Mojarra and MyFaces, with a JVM whose default locale was English, gave the same answers:

Accept-Language View locale Why

es

es

exact match with a supported locale

es-MX

es

no es_MX is supported, but the language matches

fr

en

nothing supported matches, so the default locale is used

de,es;q=0.5

es

the browser’s preference list is walked in order until a supported locale matches

fr,en;q=0.9,es;q=0.8

en

the first supported entry, en, wins

no header

the JVM default, when supported, else the default

a request without the header gets the server’s own default locale from the Servlet API

The last row hides the trap again: the "default" for a request without the header is the server’s, not the application’s default-locale. On a JVM whose default was es, a header-less request rendered in Spanish because es was a supported locale. If you want the application’s default for header-less clients, set the JVM default locale deliberately (-Duser.language=en) or keep an explicit choice in the session, as below.

The active locale can also be set by hand. UIViewRoot.getLocale() follows an algorithm that is spelled out in its Javadoc:

flowchart TD A["UIViewRoot.getLocale()"] --> B{"Was setLocale called on this view root?"} B -- "yes" --> R["Return that locale"] B -- "no" --> C{"Does the view have a locale value expression, from f:view locale?"} C -- "no" --> D["ViewHandler.calculateLocale: Accept-Language against locale-config"] C -- "yes" --> E{"What does the expression evaluate to?"} E -- "null" --> D E -- "a Locale" --> R E -- "a String" --> F["Convert the String to a Locale"] F --> R

Two consequences follow from it. A null from the expression is harmless: Faces falls back to the calculated locale. And an explicit UIViewRoot.setLocale() call wins over a f:view expression for that view; use one mechanism, not both. In a page, f:view takes a locale as a java.util.Locale or as a string in the form language[{-|_}country[{-|_}variant]], for example es or ja_JP.

A Language Switcher

The browser’s preference is a good start, but users want to override it. The pieces are a place to remember the choice (a session-scoped bean), f:view locale to apply it, and a control that changes it. The bean starts from the locale Faces would have chosen:

LocaleBean.java
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.SessionScoped;
import jakarta.faces.application.Application;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

@Named
@SessionScoped
public class LocaleBean implements Serializable {

    private Locale locale;

    @PostConstruct
    void init() {
        FacesContext context = FacesContext.getCurrentInstance();
        locale = context.getApplication().getViewHandler().calculateLocale(context);
    }

    public Locale getLocale() {
        return locale;
    }

    public String getLanguage() {
        return locale.getLanguage();
    }

    public void setLanguage(String language) {
        this.locale = Locale.forLanguageTag(language);
    }

    public List<Locale> getSupportedLocales() {
        Application application = FacesContext.getCurrentInstance().getApplication();
        List<Locale> locales = new ArrayList<>();
        locales.add(application.getDefaultLocale());
        application.getSupportedLocales().forEachRemaining(locales::add);
        return locales;
    }

    public String switchTo(String language) {
        setLanguage(language);
        return reload();
    }

    public String reload() {
        String viewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();
        return viewId + "?faces-redirect=true&includeViewParams=true";
    }
}

The layout shown earlier wraps the whole document in <f:view locale="#{localeBean.locale}"> and its language links call #{localeBean.switchTo('es')}. Those links, the bundle and the formats on the catalogue page produced the following, one request per row (JVM default locale es, so the _en bundles matter):

Accept-Language Title Count (3) Date Price

en

Bookshelf

3 books in the catalogue

December 23, 1815

€12.50

es

Estantería

3 libros en el catálogo

23 de diciembre de 1815

12,50 €

fr

Bookshelf

3 books in the catalogue

December 23, 1815

€12.50

After a click on Español the same session received Spanish on the redirected page and on the next request, on both implementations, whatever the browser’s header said.

Several details of the bean deserve attention:

  • It asks ViewHandler.calculateLocale() instead of reading UIViewRoot in @PostConstruct. The first version of the scratch bean read context.getViewRoot().getLocale(). It gave the right answer on pages that use the layout as a template client, and the wrong one (English for an es browser) on a page that had f:view as its own root and touched the bean from there. The cause was not isolated; calculateLocale gave the right answer in both cases and does not depend on the view being in any particular state.

  • f:view must wrap the content. The layout puts it around h:head and h:body. Because the locale attribute is an EL expression, the bundle variable and the converters on every page that uses the layout follow localeBean.locale without any page repeating it.

  • It redirects after switching, on purpose. With a plain action that sets the locale and returns null, Mojarra re-rendered the same postback in Spanish, while MyFaces answered the postback in the old language and showed the new one only on the next request (as if it had evaluated the f:view expression while rebuilding the view, before the action ran). Returning an outcome with faces-redirect=true makes both implementations behave identically, since the next request is a GET that sees the new locale from the start. includeViewParams=true keeps the view parameters on the redirect URL, a topic of the view parameters page in this section.

  • A direct UIViewRoot.setLocale() call also works, as the Javadoc algorithm predicts, but remember that it takes precedence over the expression for that view.

A drop-down is a small variation on the same bean. Render the supported locales as select items, bind the selection to language, and let the button reload the view:

menu.xhtml
<h:form id="menuform">
  <h:selectOneMenu id="menu" value="#{localeBean.language}">
    <f:selectItems value="#{localeBean.supportedLocales}" var="l"
                   itemValue="#{l.language}" itemLabel="#{l.getDisplayLanguage(l)}"/>
  </h:selectOneMenu>
  <h:commandButton id="apply" value="OK" action="#{localeBean.reload}"/>
</h:form>

l.getDisplayLanguage(l) asks each locale to name itself in its own language, so the menu showed English and español whatever the page language. Posting the form with es selected switched the application to Spanish.

Where a choice should survive the session, store it where it fits your application: a cookie read in the bean’s @PostConstruct, a user-profile column, or a locale segment in the URL. The session-scoped bean is the minimum, and the part of the page that depends on it, f:view locale, does not change.

Formatting Numbers and Dates by Locale

Converters read the active locale unless told otherwise. The catalogue page above shows the effect without extra work: f:convertDateTime type="localDate" dateStyle="long" printed December 23, 1815 in English and 23 de diciembre de 1815 in Spanish, and f:convertNumber type="currency" currencyCode="EUR" printed €12.50 and 12,50 €. The type="localDate" form is the way to format java.time.LocalDate; the older date and time types expect a java.util.Date.

Both converters accept a locale attribute, a java.util.Locale or a language string, that overrides the view locale for one value. That is the right tool when a number must be written one way regardless of the reader, for instance with the invoice’s own convention or a machine-readable value:

<h:outputText value="#{catalogueBean.price}">
  <f:convertNumber minFractionDigits="2" locale="en"/>
</h:outputText>

That printed 12.50 on the Spanish page, where the surrounding currency converter printed 12,50 €. For dates, timeZone is the other attribute you will want: a date is formatted in the JVM’s time zone unless the converter names one, and an international audience seldom shares the server’s zone.

Character Encoding and the Response Locale

At the start of Render Response the ViewHandler sets the response locale to the view’s locale, which can influence the response’s character encoding according to the Servlet specification. f:view also has encoding and contentType attributes to set both explicitly. In the scratch run every page, English or Spanish, rendered the accented characters correctly with no extra configuration other than the UTF-8 resource files. Keep your sources, templates and bundles in UTF-8 and the three agree.

Mojarra and MyFaces at a Glance

Everything above behaved the same on both implementations except these points, observed with Mojarra 4.1.17 and MyFaces Core 4.1.4:

Behaviour Mojarra 4.1.17 MyFaces Core 4.1.4

h:outputFormat without f:param

Value rendered unmodified, as the VDL documentation says

MessageFormat is applied anyway (an apostrophe is dropped)

f:view locale expression changed by an action

The same response is already in the new locale

The new locale shows from the next request

{1} example in conversion messages

9346 for integers

21 for integers

Quotes in message text

Written as they are

Written as &quot;, which a browser shows as "

Supported-locale matching, standard message override, _detail fallback, ???key???, setKeepMessages, choice and apostrophe rules

Identical

Identical

When you write a test that posts a form to MyFaces without a browser, remember that it also expects the hidden <formId>_SUBMIT field and identifies a link by <formId>:_idcl, while Mojarra accepts the link’s client id as a parameter. A real browser sends both correctly.

Common Mistakes

  • No label on inputs. The message ends with the client id (review:title, or a generated j_idt22). Set label from the resource bundle on every input that can fail.

  • Hard-coded English in FacesMessage. A literal summary ignores the locale. Always build it from the bundle for the view’s locale, as in the examples above.

  • Missing messages_en.properties. On a server whose JVM locale is not English, the base bundle is skipped. Keep a bundle for every supported locale, even an empty one.

  • Old javax.faces keys in a custom message bundle. In the scratch run a FacesMessages.properties whose keys started with javax.faces. was silently ignored and the default text Title: Validation Error: Value is required. appeared. Faces 3.0 renamed the namespace; the keys are jakarta.faces.* (the Faces 3.0 version number follows the Jakarta EE 9 rename and was not separately checked).

  • Apostrophes in patterns that have placeholders. Double them, or the placeholder and the quote disappear.

  • f:param with a literal for a numeric format. Pass a number through EL.

  • Messages not rendered by Ajax. Add the h:messages to the render attribute, or the user sees nothing.

  • Messages lost on redirect. Use setKeepMessages(true) (or the flash) when an action both adds a message and redirects.

  • escape="false" on a message component with user input in the text. A message that echoes what the user typed is output that must stay escaped. Do not turn escape off to show formatting; do it in the bundle text with plain characters or style classes.

  • Bundle text with a secret or a path. Bundles are readable by anyone who can reach the class path or the generated page; keep credentials and server paths out of them.

What Changed Between Versions

  • Faces 1.2: the resource-bundle application element and Application.getResourceBundle() (the Javadoc of the method carries Since: 1.2), the alternative to f:loadBundle.

  • Faces 2.3: the java.time types in f:convertDateTime (type="localDate" and friends; the 4.1 Javadoc lists them, but the change log does not say in which release they arrived, and 2.3 is the usual attribution), and the CDI producers and jakarta.faces.annotation.ManagedProperty qualifier that let a bean inject the bundle or the FacesContext. The CDI replacement for @ManagedProperty is issue 1418 in the 4.1 specification’s change log (a 2.3 item); the exact version of the producers was not re-checked on the 2026 Javadoc.

  • Faces 3.0 and 4.0: the javax.faces to jakarta.faces rename (Faces 3.0, Jakarta EE 9; the 4.1 specification lists the keys only in their jakarta.faces.* form) moved the standard bundle to jakarta.faces.Messages, the message keys to the jakarta.faces.* prefix and the Facelets namespaces to URNs such as jakarta.faces.core (f:view as written above). The javax.faces.bean.ManagedProperty annotation of the books was removed in 4.0.

  • Faces 4.1: FacesMessage gains equals(), hashCode() and toString(), and VALUES and VALUES_MAP become generic (see FacesMessage: Severity, Summary and Detail); nothing else about messages or locales that this page depends on changed, and every behaviour above was observed on 4.1 implementations.

  • Faces 5.0 (upcoming): still a milestone. A SUCCESS severity for FacesMessage (issue 1592) is planned and not available in 4.1; nothing on this page is announced as removed, but the planned removal of the pre-4.0 Facelets namespaces would affect the tag libraries the older books use.

Where the Books Are Outdated

  • The JSF 2.0 books (Burns and Schalk, Leonard) and the PrimeFaces cookbook show javax.faces.application.FacesMessage, the javax.faces.Messages bundle and the http://java.sun.com/jsf/core namespaces. The classes and tags are the same today with the jakarta.faces names and the URN namespaces jakarta.faces.core and jakarta.faces.html, but a message bundle copied with the javax.faces.* keys is ignored, as shown above.

  • They keep view text in a @ManagedBean or <managed-bean> bean and often load it with f:loadBundle. Declare the bundle once with <resource-bundle> and inject it with CDI instead.

  • Their locale switchers use @ManagedBean @SessionScoped and javax.faces.bean.ManagedProperty. The CDI version above (@Named @SessionScoped) is the current one, and it redirects after the switch to behave the same on Mojarra and MyFaces.

  • Properties files written with \uXXXX escapes or converted with native2ascii were necessary when Java read them as ISO-8859-1. Java 9 and later read them as UTF-8, so write the characters directly.

  • The books' PrimeFaces chapters show RequestContext and legacy message components; PrimeFaces 16 uses PrimeFaces.current(). The p:messages and p:growl components are discussed on the PrimeFaces Ajax and forms page.

Use the books for the reasoning about why messages carry a severity, a summary and a detail, and why locales belong to the view. Use this page and the specification for the keys and attributes to type today.

Source: Burns and Schalk, JavaServer Faces 2.0: The Complete Reference (McGraw-Hill, 2010, ISBN 978-0-07-162509-8), Leonard, JSF 2.0 Cookbook (Packt, 2010, ISBN 978-1-847199-52-2) and Jakarta Faces 4.1 specification, Localization and Internationalization