Skip to content

Internationalization

User-visible strings belong in resource bundles, not hard-coded inside templates or controllers. mangoo I/O loads messages*.properties from src/main/resources/translations/ and resolves the active locale for you on every request.

Templates call ${i18n("key")}, with arguments if you need them. Controllers take Messages as a method parameter to reach the same keys from Java. Locale selection follows a fixed order: an explicit lang parameter first, then the cookie, then the Accept-Language header, and finally the configured default.

Locale order

  1. lang query parameter, e.g. /page?lang=en. A route parameter named lang is not evaluated.

  2. i18n cookie (i18n.cookie.name, default mangooio-i18n)

  3. Accept-Language, the first header value only, lowercased. There is no q-value negotiation.

  4. application.language in config.yaml (default en). An invalid tag becomes a best-effort locale or Locale.ROOT. The configured language is always a string, so there is no further fallback after this step.

Use the configured cookie name. LocaleHandler reads i18n.cookie.name, which defaults to mangooio-i18n but can be changed. A cookie written under the default name is ignored once you change the key.

import io.mangoo.core.Config;
import io.mangoo.routing.Response;
import io.undertow.server.handlers.Cookie;
import io.undertow.server.handlers.CookieImpl;
import jakarta.inject.Inject;

public class LocaleController {
    private final Config config;

    @Inject
    public LocaleController(Config config) {
        this.config = config;
    }

    public Response localize() {
        Cookie cookie = new CookieImpl(config.getI18nCookieName(), "en");
        return Response.ok().cookie(cookie).render();
    }
}

The locale cookie is HttpOnly, Path=/, and SameSite=Strict.

Messages in Java

Take Messages as a controller method parameter. That instance is created per request and carries the locale resolved by the order above.

package controllers;

import io.mangoo.i18n.Messages;
import io.mangoo.routing.Response;

public class I18nController {
    public Response translation(Messages messages) {
        String text = messages.get("welcome");
        String named = messages.get("hello", "Ada");
        return Response.ok().bodyText(text);
    }
}
welcome=Welcome
hello=Hello {0}

get(key, arguments) uses MessageFormat. An apostrophe in a pattern that takes arguments is a quote, so write '' for a literal apostrophe. get(key) does not. A missing key is logged once per locale. After 1000 distinct locale:key pairs, further missing keys are silent.

Do not inject Messages as a field to translate a request. An injected Messages is an application-wide singleton bound to the JVM default locale, not to the locale of the current request. Only the method parameter carries the request locale.

Messages outside of a request

Background jobs, scheduled tasks and emails have no request locale to fall back on. Create an instance for the locale you actually want, for example the language stored on the recipient:

var messages = new Messages(Locale.of(user.getLanguage()));

Mail.newMail()
        .from(from)
        .subject(messages.get("email.forgot.password.subject"))
        .to(user.getUsername())
        .textMessage("emails/forgot_password.ftl", Map.of("messages", messages))
        .send();

The locale is fixed for the lifetime of an instance and readable via getLocale(). A locale without a matching messages_xx.properties falls back to the base messages.properties, never to the JVM default locale.

Templates

${i18n("welcome")}
${i18n("hello", "Ada")}

Messages.get(key) and Messages.get(key, args) fall back to the framework defaults and return an empty string when the key is absent, rather than throwing, so a missing translation degrades quietly instead of breaking the page. Each missing key is logged once as a warning per locale, so gaps in a language file still show up in the log. Text without arguments is returned as is, apostrophes are not treated as MessageFormat quotes. See Templating.