In this article, you will learn how to translate API exceptions, emails, webpages and validation messages in Spring Boot 3.5 and Java 24 using
translations from messages.properties
files.
This guide comes with a complete example application that demonstrates how to set up internationalization (i18n) in a Spring Boot application,
manage translations using SimpleLocalize, and use them in Thymeleaf templates and controllers.
Installation
Add the required dependencies to your pom.xml
for Maven:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Optional: For JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
Or for Gradle (build.gradle
):
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Optional: For JSON processing
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
Configuration
Configure internationalization
Create an internationalization configuration class:
// src/main/java/com/example/config/InternationalizationConfig.java
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import java.util.Locale;
@Configuration
public class InternationalizationConfig implements WebMvcConfigurer {
@Bean
public ReloadableResourceBundleMessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasename("classpath:messages/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(3600); // Cache for 1 hour
messageSource.setFallbackToSystemLocale(false);
messageSource.setDefaultLocale(Locale.ENGLISH);
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver localeResolver = new CookieLocaleResolver();
localeResolver.setDefaultLocale(Locale.ENGLISH);
localeResolver.setCookieName("language");
localeResolver.setCookieMaxAge(3600 * 24 * 30); // 30 days
return localeResolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
Application properties
Configure your application.yml
:
# src/main/resources/application.yml
spring:
application:
name: spring-boot-i18n-app
# Message source configuration
messages:
basename: messages/messages
encoding: UTF-8
cache-duration: 3600s
fallback-to-system-locale: false
# Thymeleaf configuration
thymeleaf:
encoding: UTF-8
mode: HTML
cache: false # Set to true in production
# Server configuration
server:
port: 8080
# Logging
logging:
level:
com.example: DEBUG
org.springframework.context.support: DEBUG
Load translations
Create properties files in the src/main/resources/messages/
directory:
src/main/resources/
messages/
messages.properties # Default (English)
messages_en.properties # English
messages_es.properties # Spanish
messages_fr.properties # French
messages_de.properties # German
Example translation files:
# src/main/resources/messages/messages_en.properties
nav.home=Home
nav.about=About
nav.services=Services
nav.contact=Contact
home.title=Welcome to Our Application
home.subtitle=This is a multilingual Spring Boot application
home.description=Built with Spring Boot and SimpleLocalize
# cut off for brevity
# src/main/resources/messages/messages_es.properties
nav.home=Inicio
nav.about=Acerca de
nav.services=Servicios
nav.contact=Contacto
home.title=Bienvenido a Nuestra Aplicación
home.subtitle=Esta es una aplicación Spring Boot multiidioma
home.description=Construida con Spring Boot y SimpleLocalize
# cut off for brevity
Both options are valid, and you can choose the one that fits your needs.
Usage
Using in Thymeleaf templates
Use the #messages
utility in your Thymeleaf templates:
<!-- src/main/resources/templates/index.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="#{home.title}">Home</title>
</head>
<body>
<nav class="navigation">
<a href="/" th:text="#{nav.home}">Home</a>
<a href="/about" th:text="#{nav.about}">About</a>
<a href="/services" th:text="#{nav.services}">Services</a>
<a href="/contact" th:text="#{nav.contact}">Contact</a>
<!-- Language switcher -->
<div class="language-switcher">
<a href="?lang=en" th:classappend="${#locale.language == 'en'} ? 'active' : ''">🇺🇸 EN</a>
<a href="?lang=es" th:classappend="${#locale.language == 'es'} ? 'active' : ''">🇪🇸 ES</a>
<a href="?lang=fr" th:classappend="${#locale.language == 'fr'} ? 'active' : ''">🇫🇷 FR</a>
<a href="?lang=de" th:classappend="${#locale.language == 'de'} ? 'active' : ''">🇩🇪 DE</a>
</div>
</nav>
<main>
<h1 th:text="#{home.title}">Welcome</h1>
<p th:text="#{home.subtitle}">Subtitle</p>
<p th:text="#{home.description}">Description</p>
</main>
</body>
</html>
Using with parameters
Use parameters in your translations:
<!-- In Thymeleaf template -->
<p th:text="#{welcome.message(${username}, ${itemCount})}">Welcome message</p>
Properties file:
welcome.message=Hello {0}, you have {1} items
Using in controllers
Inject MessageSource
to use translations in your controllers:
// src/main/java/com/example/controller/HomeController.java
package com.example.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Locale;
@Controller
public class HomeController {
@Autowired
private MessageSource messageSource;
@GetMapping("/")
public String home(Model model, Locale locale) {
String welcomeMessage = messageSource.getMessage("home.title", null, locale);
model.addAttribute("welcomeMessage", welcomeMessage);
return "index";
}
@GetMapping("/about")
public String about(Model model, Locale locale) {
String pageTitle = messageSource.getMessage("about.title", null, "About Us", locale);
model.addAttribute("pageTitle", pageTitle);
return "about";
}
@GetMapping("/api/message")
public ResponseEntity<Map<String, String>> getMessage(
@RequestParam String key,
Locale locale) {
String message = messageSource.getMessage(key, null, key, locale);
Map<String, String> response = Map.of("message", message, "locale", locale.toString());
return ResponseEntity.ok(response);
}
}
Contact form with validation
Create a contact form with internationalized validation messages:
// src/main/java/com/example/dto/ContactForm.java
package com.example.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class ContactForm {
@NotBlank(message = "{validation.required}")
@Size(min = 2, max = 50, message = "{validation.name.size}")
private String name;
@NotBlank(message = "{validation.required}")
@Email(message = "{validation.email}")
private String email;
@NotBlank(message = "{validation.required}")
@Size(min = 5, max = 100, message = "{validation.subject.size}")
private String subject;
@NotBlank(message = "{validation.required}")
@Size(min = 10, max = 1000, message = "{validation.message.size}")
private String message;
// Constructors, getters, and setters
public ContactForm() {}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
Contact form controller:
// src/main/java/com/example/controller/ContactController.java
package com.example.controller;
import com.example.dto.ContactForm;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.Locale;
@Controller
public class ContactController {
@Autowired
private MessageSource messageSource;
@GetMapping("/contact")
public String contactForm(Model model) {
model.addAttribute("contactForm", new ContactForm());
return "contact";
}
@PostMapping("/contact")
public String submitContact(
@Valid ContactForm contactForm,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes,
Locale locale) {
if (bindingResult.hasErrors()) {
return "contact";
}
// Process the form (send email, save to database, etc.)
// ...
String successMessage = messageSource.getMessage("contact.success", null, locale);
redirectAttributes.addFlashAttribute("successMessage", successMessage);
return "redirect:/contact";
}
}
Contact form template:
<!-- src/main/resources/templates/contact.html -->
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="#{contact.title}">Contact</title>
</head>
<body>
<div class="container">
<h1 th:text="#{contact.title}">Contact Us</h1>
<!-- Success message -->
<div th:if="${successMessage}" class="alert alert-success" th:text="${successMessage}"></div>
<form th:action="@{/contact}" th:object="${contactForm}" method="post" class="contact-form">
<div class="form-group">
<label for="name" th:text="#{contact.form.name}">Name</label>
<input type="text" id="name" th:field="*{name}"
th:placeholder="#{contact.form.name}"
th:classappend="${#fields.hasErrors('name')} ? 'error' : ''"/>
<div th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error-message"></div>
</div>
<div class="form-group">
<label for="email" th:text="#{contact.form.email}">Email</label>
<input type="email" id="email" th:field="*{email}"
th:placeholder="#{contact.form.email}"
th:classappend="${#fields.hasErrors('email')} ? 'error' : ''"/>
<div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error-message"></div>
</div>
<div class="form-group">
<label for="subject" th:text="#{contact.form.subject}">Subject</label>
<input type="text" id="subject" th:field="*{subject}"
th:placeholder="#{contact.form.subject}"
th:classappend="${#fields.hasErrors('subject')} ? 'error' : ''"/>
<div th:if="${#fields.hasErrors('subject')}" th:errors="*{subject}" class="error-message"></div>
</div>
<div class="form-group">
<label for="message" th:text="#{contact.form.message}">Message</label>
<textarea id="message" th:field="*{message}" rows="5"
th:placeholder="#{contact.form.message}"
th:classappend="${#fields.hasErrors('message')} ? 'error' : ''"></textarea>
<div th:if="${#fields.hasErrors('message')}" th:errors="*{message}" class="error-message"></div>
</div>
<div class="form-actions">
<button type="submit" th:text="#{contact.form.submit}">Send Message</button>
</div>
</form>
</div>
</body>
</html>
Managing translations
Install SimpleLocalize CLI
# macOS / Linux / Windows (WSL)
curl -s https://get.simplelocalize.io/2.9/install | bash
# Windows (PowerShell)
. { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex;
# npm
npm install @simplelocalize/cli
Configure SimpleLocalize
Create a simplelocalize.yml
file in the root of your project:
apiKey: YOUR_PROJECT_API_KEY
# Upload configuration
uploadFormat: java-properties
uploadPath: ./src/main/resources/messages/messages_en.properties
uploadLanguageKey: en
uploadOptions:
- REPLACE_TRANSLATION_IF_FOUND
# Download configuration
downloadPath: ./src/main/resources/messages/messages_{lang}.properties
downloadLanguageKeys: ['en', 'es', 'fr', 'de']
downloadFormat: java-properties
downloadOptions:
- CREATE_DIRECTORIES
Upload source translations
Upload your source (English) translations to SimpleLocalize:
simplelocalize upload
This will upload the messages_en.properties
file to SimpleLocalize as your source translations.
Manage translations
Now you can manage and translate your messages in the SimpleLocalize translation editor:
- Add new languages in the Languages tab
- Use auto-translation to quickly translate into multiple languages
- Manually refine translations for better quality
- Add context and descriptions to help translators
Download translations
Download completed translations to your project:
simplelocalize download
This will create/update translation files in ./src/main/resources/messages/
directory:
messages_en.properties
messages_es.properties
messages_fr.properties
messages_de.properties
Build workflow
Add these scripts to your build.gradle
for a complete workflow:
// Add to build.gradle
task downloadTranslations(type: Exec) {
commandLine 'simplelocalize', 'download'
}
task uploadTranslations(type: Exec) {
commandLine 'simplelocalize', 'upload'
}
// Download translations before building
build.dependsOn downloadTranslations
Or for Maven (pom.xml
):
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>download-translations</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>simplelocalize</executable>
<arguments>
<argument>download</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
Custom validation messages
Add custom validation messages to your properties files:
# Validation messages in messages_en.properties
validation.required=This field is required
validation.email=Please enter a valid email address
validation.name.size=Name must be between {2} and {1} characters
validation.subject.size=Subject must be between {2} and {1} characters
validation.message.size=Message must be between {2} and {1} characters
# Bean validation messages
jakarta.validation.constraints.NotBlank.message={validation.required}
jakarta.validation.constraints.Email.message={validation.email}
jakarta.validation.constraints.Size.message=Must be between {min} and {max} characters
Locale detection from request headers
Create a custom locale resolver that detects locale from Accept-Language header:
// src/main/java/com/example/config/CustomLocaleResolver.java
package com.example.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.LocaleResolver;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
public class CustomLocaleResolver implements LocaleResolver {
private static final List<String> SUPPORTED_LANGUAGES = Arrays.asList("en", "es", "fr", "de");
private static final Locale DEFAULT_LOCALE = Locale.ENGLISH;
@Override
public Locale resolveLocale(HttpServletRequest request) {
String acceptLanguage = request.getHeader("Accept-Language");
if (acceptLanguage == null || acceptLanguage.isEmpty()) {
return DEFAULT_LOCALE;
}
String[] languages = acceptLanguage.split(",");
for (String language : languages) {
String langCode = language.split("-")[0].split(";")[0].trim();
if (SUPPORTED_LANGUAGES.contains(langCode)) {
return new Locale(langCode);
}
}
return DEFAULT_LOCALE;
}
@Override
public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
// Not implemented for header-based resolution
}
}
Resources
- Docs: Spring Boot Internationalization
- Docs: Spring Framework MessageSource
- Docs: Thymeleaf Internationalization
- Docs: SimpleLocalize CLI Documentation
- Docs: Java Properties File Format
- Blog: Spring Boot 3.5: Internationalization
- Java 24: Internationalization
- GitHub: simplelocalize/spring-boot-i18n