Spring Boot 3.5: Internationalization (i18n) with messages.properties

Jakub Pomykała
Jakub Pomykała
Last updated: July 04, 202510 min read
Spring Boot 3.5: Internationalization (i18n) with messages.properties

Introduction

Internationalization (i18n) is the process of designing your application to be adapted to various languages, regional peculiarities, and technical requirements of a target market. Internationalization is a crucial step in the process of localizing your product. In this tutorial, we will show you how to use Spring Boot to create a simple internationalized application, how to get translated messages from messages_xx.properties files, render translated HTML from HTML template using Thymeleaf and how to use LocaleResolver to change the language of the application.

spring-boot-i18n

Used technologies

  • Java 21+ (LTS recommended)
  • Spring Boot 3.5.4
  • Thymeleaf 3.1.3
  • Maven
Spring Boot 3.5 with i18n
Spring Boot 3.5 with i18n

Configuration

1. Create messages_xx.properties files

The messages are stored in the messages_XX.properties files. The XX is the language code. For example, messages_pl_PL.properties is the Polish version of the messages.

footerText=© 2025 SimpleLocalize. Wszelkie prawa zastrzeżone.
linkText=Utwórz konto SimpleLocalize
message=Dziękujemy za wypróbowanie naszego demo SimpleLocalize dla Spring Boot!
title=Hej {0}!

With SimpleLocalize, managing translations is effortless. Upload your source language from *.properties, and get translations in other languages automatically using machine translation, AI, or by collaborating with your own translators. You can automate uploading and downloading messages_xxx.properties files via SimpleLocalize CLI.

Downloading messages_xx.properties files via SimpleLocalize CLI

2. Configure messages_xx.properties location

The default location for messages is src/main/resources/messages. You can change this by setting the spring.messages.basename property in your application.properties file or by providing your ResourceBundleMessageSource bean.

Application properties configuration:

# application.properties - Spring Boot 3.5
spring.messages.basename=i18n/messages
spring.messages.use-code-as-default-message=true
spring.messages.encoding=UTF-8
spring.messages.fallback-to-system-locale=false
spring.messages.cache-duration=3600

Java Configuration using ResourceBundleMessageSource:

@Bean
public ResourceBundleMessageSource messageSource() {
    var resourceBundleMessageSource = new ResourceBundleMessageSource();
    resourceBundleMessageSource.setBasenames("i18n/messages"); // directory with messages_XX.properties
    resourceBundleMessageSource.setUseCodeAsDefaultMessage(true); // if message not found, use the key as default message
    resourceBundleMessageSource.setDefaultLocale(Locale.of("en")); // default locale
    resourceBundleMessageSource.setDefaultEncoding("UTF-8"); // encoding of the messages files
    return resourceBundleMessageSource;
}

Please note that not every setting is available in application.properties file.

Managing messages.properties in IntelliJ IDEA
Managing messages.properties in IntelliJ IDEA

3. Configure resolving locale from requests

The default locale resolver is AcceptHeaderLocaleResolver which resolves the locale from the Accept-Language header. You can change this by setting the spring.mvc.locale-resolver property in your application.properties file or by providing your LocaleResolver bean, creating LocaleChangeInterceptor and registering it via addInterceptors method (see WebMvcConfigurer class).

@Bean
public LocaleResolver localeResolver() {
    SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver();
    sessionLocaleResolver.setDefaultLocale(Locale.of("en"));
    return sessionLocaleResolver;
}

@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
    LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
    localeChangeInterceptor.setParamName("lang");
    return localeChangeInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(localeChangeInterceptor());
}

4. Create HTML template with Thymeleaf

One of the most common use cases for internationalization in Spring Boot is to render HTML pages (or emails) with translated messages. In this case, we will use Thymeleaf as it's one of the most popular template engines in the Java ecosystem and it works great with Spring Boot, and it supports internationalization out of the box.

<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" th:attr="lang=${lang}">

<head>
  <title>Spring Boot Email Example</title>
</head>

<body>
<header>
  <h1 th:utext="#{title(${userName})}"></h1>
</header>

<article>
  <p th:text="#{message}"></p>
  <a th:href="${url}" th:text="#{linkText}"></a>
</article>
<footer>
  <p th:text="#{footerText}"></p>
</footer>
</body>

</html>

You might want to learn about what is 'hreflang' and how to use it if you are creating multilingual app or website.

Quick Thymeleaf guide:

  • th:attr="lang=${lang}" - sets the language of the document
  • th:text="#{message}" - gets a message with message key from messages_xx.properties
  • th:utext="#{title(${userName})}" - same as th:text, but allows you to use variables in the message, and it does not escape HTML characters
  • th:href="${url}" - inserts a value of the url variable

Tip: If you are creating email templates, you may want to check mjml.io that allows you to create responsive email templates using a simple markup language.

Get translated messages

Use MessageSource to get translated messages. This is the default way to get translated messages in Spring Boot.

import org.springframework.context.MessageSource;

@Autowired
private MessageSource messageSource;

@Test
void shouldGetTranslatedTextFromLocalFileAndLocale() {
    //given
    Locale locale = Locale.of("pl","PL");

    //when
    String titleTextWithArgument=messageSource.getMessage("title",new Object[]{"Foo Bar"},locale);

    //then
    assert titleTextWithArgument.equals("Hej Foo Bar!");
}

Translate API exceptions

You can return translated API exceptions by using standard Spring Boot @ControllerAdvice. For these purposes, we will create a custom ErrorController that handles exceptions and returns translated messages.

  • @ExceptionHandler(Exception.class) - catches all exceptions not caught by other methods
  • @ExceptionHandler(IllegalArgumentException.class) - catches IllegalArgumentException exceptions

Both methods gets Locale from LocaleContextHolder and returns translated message from messages_xx.properties file using MessageSource bean.


@RestControllerAdvice
public class ErrorController
{
  @Autowired
  private MessageSource messageSource;

  @ExceptionHandler(Exception.class)
  @ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
  public ResponseEntity<ApiError> exception()
  {
    String message = getLocalizedMessage("exception.internalServerError");
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    return ResponseEntity
            .status(status)
            .body(new ApiError(message, status));
  }

  @ExceptionHandler(IllegalArgumentException.class)
  @ResponseStatus(code = HttpStatus.BAD_REQUEST)
  public ResponseEntity<ApiError> badRequest()
  {
    String message = getLocalizedMessage("exception.badRequest");
    HttpStatus status = HttpStatus.BAD_REQUEST;
    return ResponseEntity
            .status(status)
            .body(new ApiError(message, status));
  }

  private String getLocalizedMessage(String translationKey)
  {
    Locale locale = LocaleContextHolder.getLocale();
    return messageSource.getMessage(translationKey, null, locale);
  }
}

By default, Spring Boot uses Accept-Language header to resolve user locale, but in this tutorial we changed this behavior by registering custom LocaleChangeInterceptor bean. To change the language of exception, you need to use lang parameter, e.g. /api/my-health?lang=pl_PL.

Translated Spring Boot exceptions

The number of ways to return translated messages is endless, but the solution above might be one of the best ones as we keep all the logic and translation keys for exceptions in one place.

Translate @Validation messages

Spring Boot provides support for localizing validation error messages using the Bean Validation API (@Validated, @Valid) with custom message properties.

Create a LocalValidatorFactoryBean bean to configure the validation messages:

@Configuration
public class Configuration {

    @Bean
    public LocalValidatorFactoryBean getValidator() {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource()); // Use the message source bean to resolve validation messages
        return bean;
    }
}

Create a request DTO with validation annotations and localized messages:

public record CreateUserRequest(
    @NotBlank(message = "{error.user.name.notblank}")
    String name,

    @Email(message = "{error.user.email.invalid}")
    @NotBlank(message = "{error.user.email.invalid}")
    String email

) {}

Make sure to create the corresponding messages in your messages_xx.properties files:

# messages_pl_PL.properties
error.user.name.notblank=Imię nie może być puste.
error.user.email.invalid=Adres e-mail jest nieprawidłowy.
error.user.email.notblank=Adres e-mail nie może być pusty.

Create a sample controller to handle user creation requests with validation:

@RestController
@Validated
public class UserController {

    @PostMapping("/api/users")
    public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
        return ResponseEntity.ok("User created successfully");
    }
}

Create a global exception handler to handle validation errors and return localized messages:

@RestControllerAdvice
public class ErrorController
{
  @ExceptionHandler(BindException.class)
  @ResponseStatus(code = HttpStatus.BAD_REQUEST)
  public ResponseEntity<ApiError> validationFailed(BindException exception)
  {
    List<String> errors = exception.getFieldErrors()
            .stream()
            .map(fieldError -> {
              String field = fieldError.getField();
              String defaultMessage = fieldError.getDefaultMessage();
              return field + ": " + defaultMessage;
            })
            .toList();
    ApiError apiError = new ApiError(errors);
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiError);
  }

}

Translate web pages

You can also return translated web pages (HTML) by using standard Spring Boot @Controller. Spring Boot will automatically resolve user locale and render HTML from my-html-template.html template with translated messages.

This is the default way to return translated web pages in Spring Boot.

@Controller
public class WelcomeController
{
  @GetMapping("/welcome")
  public String renderHtmlFromTemplate(Model model)
  {
    model.addAttribute("userName", "Jakub");
    return "my-html-template";
  }
}

Run the application and open http://localhost:8080/welcome in your browser. You can change the language by adding ?lang=pl_PL to the URL.

Changing the 'lang' query parameter in Spring Boot 3.5

Translate any HTML content

Use ThymeleafEngine bean to render HTML with translated messages. This is probably the most popular way to render HTML with translated messages in Spring Boot.

@Autowired
private TemplateEngine templateEngine;

public String renderHtmlFromTemplate(Locale locale, String userName)
{
    Context context = new Context();
    context.setLocale(locale);
    context.setVariable("userName",userName);
    context.setVariable("lang",locale.getLanguage());
    context.setVariable("url","https://simplelocalize.io");
    return templateEngine.process("my-html-template",context);
}
Rendering custom HTML with translated texts
Rendering custom HTML with translated texts

Handling Accept-Language Header

Default Behavior with AcceptHeaderLocaleResolver:

Spring Boot uses AcceptHeaderLocaleResolver by default, which automatically parses this header:

@RestController
public class LocaleTestController {

    @GetMapping("/api/locale-info")
    public Map<String, Object> getLocaleInfo(HttpServletRequest request) {
        // Get the resolved locale
        Locale resolvedLocale = LocaleContextHolder.getLocale();

        // Get the raw Accept-Language header
        String acceptLanguageHeader = request.getHeader("Accept-Language");

        return Map.of(
            "resolvedLocale", resolvedLocale.toString(),
            "acceptLanguageHeader", acceptLanguageHeader,
            "supportedLocales", List.of("en", "pl", "de", "fr")
        );
    }
}

Custom Accept-Language Processing:

For more control over Accept-Language processing, you can create a custom resolver:

@Component
public class CustomAcceptHeaderLocaleResolver extends AcceptHeaderLocaleResolver {

    private final List<Locale> supportedLocales = List.of(
        Locale.of("en"),
        Locale.of("pl"),
        Locale.of("de"),
        Locale.of("fr")
    );

    @Override
    public Locale resolveLocale(HttpServletRequest request) {
        String acceptLanguage = request.getHeader("Accept-Language");

        if (acceptLanguage == null || acceptLanguage.isEmpty()) {
            return getDefaultLocale();
        }

        // Parse Accept-Language header manually for custom logic
        List<Locale.LanguageRange> languageRanges = Locale.LanguageRange.parse(acceptLanguage);
        Locale bestMatch = Locale.lookup(languageRanges, supportedLocales);

        return bestMatch != null ? bestMatch : getDefaultLocale();
    }

    @Override
    public Locale getDefaultLocale() {
        return Locale.of("en");
    }
}

Testing Accept-Language Header:

You can test different language preferences using curl or browser developer tools:

# Test with Polish preference
curl -H "Accept-Language: pl,en;q=0.9" http://localhost:8080/api/locale-info

# Test with German preference
curl -H "Accept-Language: de-DE,de;q=0.9,en;q=0.8" http://localhost:8080/api/locale-info

# Test with unsupported language (fallback to default)
curl -H "Accept-Language: ja,ko;q=0.9" http://localhost:8080/api/locale-info

When using LocaleChangeInterceptor with query parameters (like ?lang=pl), it takes precedence over the Accept-Language header.

Sanitization of user input

When rendering user input in HTML templates or email, you watchout for XSS (Cross-Site Scripting) attacks. For example, if you render user input directly in the HTML template without sanitization, it can lead to XSS vulnerabilities.

Thymeleaf automatically escapes HTML characters in text attributes, like th:text:

<p th:text="${userInput}"></p>

but if you use th:utext to render unescaped HTML, you must ensure that the content is safe or sanitize it before rendering.

For example, if you render user input directly in the HTML template using th:utext, it can lead to XSS vulnerabilities:

<p th:utext="${userInput}"></p>

utext stands for "unescaped text" and it allows you to render HTML content without escaping it.

To prevent XSS vulnerabilities, you can use a library like OWASP Java HTML Sanitizer to sanitize the user input before rendering it in the HTML template:

@Component
public class SecureMessageResolver {

    private final MessageSource messageSource;
    private final HtmlSanitizer htmlSanitizer;

    public String getSecureMessage(String key, Locale locale, Object... args) {
        String message = messageSource.getMessage(key, args, locale);

        // Sanitize HTML content to prevent XSS
        return htmlSanitizer.sanitize(message);
    }

    @Cacheable(value = "translations", key = "#key + '_' + #locale.language")
    public String getCachedMessage(String key, Locale locale) {
        return getSecureMessage(key, locale);
    }
}
Jakub Pomykała
Jakub Pomykała
Founder of SimpleLocalize
Ready to say
|

Greet your customers
in their native language

Start for free
5-minute setupNo credit card required