Spring Boot provides comprehensive support for internationalization (i18n) through its MessageSource infrastructure, LocaleResolver strategies, and seamless integration with template engines like Thymeleaf. This guide covers everything you need to build fully localized Spring Boot applications, from basic setup to advanced patterns including REST APIs, validation messages, and external translation management.
Spring Boot i18n Fundamentals
Spring Boot's i18n support is built on Java's ResourceBundle mechanism with additional features for web applications. The core components are MessageSource for message resolution and LocaleResolver for determining the user's locale.
Project Setup
XML1<!-- pom.xml --> 2<dependencies> 3 <!-- Spring Boot Starter Web (includes i18n support) --> 4 <dependency> 5 <groupId>org.springframework.boot</groupId> 6 <artifactId>spring-boot-starter-web</artifactId> 7 <version>3.2.1</version> 8 </dependency> 9 10 <!-- Thymeleaf (for template i18n) --> 11 <dependency> 12 <groupId>org.springframework.boot</groupId> 13 <artifactId>spring-boot-starter-thymeleaf</artifactId> 14 </dependency> 15 16 <!-- Validation (for validation message i18n) --> 17 <dependency> 18 <groupId>org.springframework.boot</groupId> 19 <artifactId>spring-boot-starter-validation</artifactId> 20 </dependency> 21</dependencies>
Directory Structure
src/
├── main/
│ ├── java/
│ │ └── com/example/app/
│ │ ├── config/
│ │ │ └── I18nConfig.java
│ │ ├── controller/
│ │ │ └── HomeController.java
│ │ └── Application.java
│ └── resources/
│ ├── messages.properties # Default (English)
│ ├── messages_es.properties # Spanish
│ ├── messages_fr.properties # French
│ ├── messages_de.properties # German
│ ├── ValidationMessages.properties # Default validation
│ ├── ValidationMessages_es.properties # Spanish validation
│ └── templates/
│ └── index.html
MessageSource Configuration
MessageSource is Spring's abstraction for resolving messages from properties files.
Basic Configuration
JAVA1// src/main/resources/application.yml 2spring: 3 messages: 4 basename: messages 5 encoding: UTF-8 6 cache-duration: 3600 # Cache for 1 hour in production 7 fallback-to-system-locale: false 8 always-use-message-format: false
Advanced Configuration
JAVA1package com.example.app.config; 2 3import org.springframework.context.MessageSource; 4import org.springframework.context.annotation.Bean; 5import org.springframework.context.annotation.Configuration; 6import org.springframework.context.support.ReloadableResourceBundleMessageSource; 7import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; 8 9@Configuration 10public class I18nConfig { 11 12 @Bean 13 public MessageSource messageSource() { 14 ReloadableResourceBundleMessageSource messageSource = 15 new ReloadableResourceBundleMessageSource(); 16 17 messageSource.setBasename("classpath:messages"); 18 messageSource.setDefaultEncoding("UTF-8"); 19 messageSource.setCacheSeconds(3600); 20 messageSource.setFallbackToSystemLocale(false); 21 22 return messageSource; 23 } 24 25 @Bean 26 public LocalValidatorFactoryBean validator(MessageSource messageSource) { 27 LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); 28 bean.setValidationMessageSource(messageSource); 29 return bean; 30 } 31}
Message Properties Files
PROPERTIES1# messages.properties (Default English) 2app.title=My Application 3app.welcome=Welcome, {0}! 4app.description=This is a localized Spring Boot application 5 6button.submit=Submit 7button.cancel=Cancel 8button.save=Save 9 10error.not_found=Resource not found 11error.unauthorized=You are not authorized to access this resource 12error.server_error=An unexpected error occurred 13 14# Plural support with MessageFormat 15cart.items={0, choice, 0#No items|1#1 item|1<{0} items} in your cart
PROPERTIES1# messages_es.properties (Spanish) 2app.title=Mi Aplicación 3app.welcome=¡Bienvenido, {0}! 4app.description=Esta es una aplicación Spring Boot localizada 5 6button.submit=Enviar 7button.cancel=Cancelar 8button.save=Guardar 9 10error.not_found=Recurso no encontrado 11error.unauthorized=No estás autorizado para acceder a este recurso 12error.server_error=Ocurrió un error inesperado 13 14cart.items={0, choice, 0#Sin artículos|1#1 artículo|1<{0} artículos} en tu carrito
PROPERTIES1# messages_fr.properties (French) 2app.title=Mon Application 3app.welcome=Bienvenue, {0} ! 4app.description=Ceci est une application Spring Boot localisée 5 6button.submit=Soumettre 7button.cancel=Annuler 8button.save=Enregistrer 9 10error.not_found=Ressource introuvable 11error.unauthorized=Vous n'êtes pas autorisé à accéder à cette ressource 12error.server_error=Une erreur inattendue s'est produite 13 14cart.items={0, choice, 0#Aucun article|1#1 article|1<{0} articles} dans votre panier
LocaleResolver Strategies
LocaleResolver determines which locale to use for each request.
1. AcceptHeaderLocaleResolver (Default)
Uses the Accept-Language HTTP header sent by the browser.
JAVA1@Configuration 2public class I18nConfig implements WebMvcConfigurer { 3 4 @Bean 5 public LocaleResolver localeResolver() { 6 AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); 7 resolver.setDefaultLocale(Locale.ENGLISH); 8 return resolver; 9 } 10}
Pros: Works automatically, respects user's browser settings Cons: Users can't change language without changing browser settings
2. SessionLocaleResolver
Stores locale in HTTP session.
JAVA1@Bean 2public LocaleResolver localeResolver() { 3 SessionLocaleResolver resolver = new SessionLocaleResolver(); 4 resolver.setDefaultLocale(Locale.ENGLISH); 5 return resolver; 6} 7 8@Bean 9public LocaleChangeInterceptor localeChangeInterceptor() { 10 LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); 11 interceptor.setParamName("lang"); // URL param: ?lang=es 12 return interceptor; 13} 14 15@Override 16public void addInterceptors(InterceptorRegistry registry) { 17 registry.addInterceptor(localeChangeInterceptor()); 18}
Usage: Visit /page?lang=es to switch to Spanish
3. CookieLocaleResolver
Stores locale in a cookie.
JAVA1@Bean 2public LocaleResolver localeResolver() { 3 CookieLocaleResolver resolver = new CookieLocaleResolver(); 4 resolver.setDefaultLocale(Locale.ENGLISH); 5 resolver.setCookieName("user-locale"); 6 resolver.setCookieMaxAge(3600 * 24 * 365); // 1 year 7 return resolver; 8}
Pros: Persists across sessions, respects user choice Cons: Requires cookie consent in some regions
4. Custom LocaleResolver (Database-Based)
Store user's locale preference in database.
JAVA1public class CustomLocaleResolver implements LocaleResolver { 2 3 @Autowired 4 private UserRepository userRepository; 5 6 @Override 7 public Locale resolveLocale(HttpServletRequest request) { 8 // Get authenticated user 9 Authentication auth = SecurityContextHolder.getContext() 10 .getAuthentication(); 11 12 if (auth != null && auth.isAuthenticated()) { 13 User user = userRepository.findByUsername(auth.getName()); 14 if (user != null && user.getPreferredLanguage() != null) { 15 return Locale.forLanguageTag(user.getPreferredLanguage()); 16 } 17 } 18 19 // Fallback to Accept-Language header 20 String acceptLanguage = request.getHeader("Accept-Language"); 21 if (acceptLanguage != null && !acceptLanguage.isEmpty()) { 22 return Locale.forLanguageTag(acceptLanguage.split(",")[0]); 23 } 24 25 return Locale.ENGLISH; 26 } 27 28 @Override 29 public void setLocale(HttpServletRequest request, 30 HttpServletResponse response, 31 Locale locale) { 32 Authentication auth = SecurityContextHolder.getContext() 33 .getAuthentication(); 34 35 if (auth != null && auth.isAuthenticated()) { 36 User user = userRepository.findByUsername(auth.getName()); 37 if (user != null) { 38 user.setPreferredLanguage(locale.toLanguageTag()); 39 userRepository.save(user); 40 } 41 } 42 } 43}
Using Messages in Controllers
Injecting MessageSource
JAVA1package com.example.app.controller; 2 3import org.springframework.beans.factory.annotation.Autowired; 4import org.springframework.context.MessageSource; 5import org.springframework.context.i18n.LocaleContextHolder; 6import org.springframework.stereotype.Controller; 7import org.springframework.ui.Model; 8import org.springframework.web.bind.annotation.GetMapping; 9 10@Controller 11public class HomeController { 12 13 @Autowired 14 private MessageSource messageSource; 15 16 @GetMapping("/") 17 public String home(Model model) { 18 Locale locale = LocaleContextHolder.getLocale(); 19 20 String welcome = messageSource.getMessage( 21 "app.welcome", 22 new Object[]{"John"}, 23 locale 24 ); 25 26 model.addAttribute("welcomeMessage", welcome); 27 return "index"; 28 } 29 30 @GetMapping("/cart") 31 public String cart(Model model) { 32 int itemCount = 5; 33 Locale locale = LocaleContextHolder.getLocale(); 34 35 String cartMessage = messageSource.getMessage( 36 "cart.items", 37 new Object[]{itemCount}, 38 locale 39 ); 40 41 model.addAttribute("cartMessage", cartMessage); 42 return "cart"; 43 } 44}
REST API with i18n
JAVA1package com.example.app.controller; 2 3import org.springframework.beans.factory.annotation.Autowired; 4import org.springframework.context.MessageSource; 5import org.springframework.context.i18n.LocaleContextHolder; 6import org.springframework.http.ResponseEntity; 7import org.springframework.web.bind.annotation.*; 8 9@RestController 10@RequestMapping("/api") 11public class ApiController { 12 13 @Autowired 14 private MessageSource messageSource; 15 16 @GetMapping("/greet") 17 public ResponseEntity<ApiResponse> greet( 18 @RequestParam String name, 19 @RequestHeader(value = "Accept-Language", required = false) String lang 20 ) { 21 Locale locale = LocaleContextHolder.getLocale(); 22 23 String message = messageSource.getMessage( 24 "app.welcome", 25 new Object[]{name}, 26 locale 27 ); 28 29 return ResponseEntity.ok(new ApiResponse(message, locale.getLanguage())); 30 } 31 32 @GetMapping("/products/{id}") 33 public ResponseEntity<?> getProduct(@PathVariable Long id) { 34 Product product = productService.findById(id); 35 36 if (product == null) { 37 String errorMessage = messageSource.getMessage( 38 "error.not_found", 39 null, 40 LocaleContextHolder.getLocale() 41 ); 42 43 return ResponseEntity.status(404) 44 .body(new ErrorResponse(errorMessage)); 45 } 46 47 return ResponseEntity.ok(product); 48 } 49} 50 51record ApiResponse(String message, String language) {} 52record ErrorResponse(String error) {}
Thymeleaf Template i18n
Thymeleaf integrates seamlessly with Spring's MessageSource using #{...} syntax.
Basic Template Usage
HTML1<!DOCTYPE html> 2<html xmlns:th="http://www.thymeleaf.org"> 3<head> 4 <title th:text="#{app.title}">My Application</title> 5 <meta charset="UTF-8"> 6</head> 7<body> 8 <!-- Simple message --> 9 <h1 th:text="#{app.title}">Default Title</h1> 10 11 <!-- Message with parameter --> 12 <p th:text="#{app.welcome('John')}">Welcome, John!</p> 13 14 <!-- Using variable --> 15 <p th:text="#{app.welcome(${username})}">Welcome message</p> 16 17 <!-- Button labels --> 18 <button th:text="#{button.submit}">Submit</button> 19 <button th:text="#{button.cancel}">Cancel</button> 20 21 <!-- Link with message --> 22 <a th:href="@{/about}" th:text="#{nav.about}">About</a> 23</body> 24</html>
Advanced Template Patterns
HTML1<!-- Using message as attribute --> 2<input type="text" 3 th:placeholder="#{form.email.placeholder}" 4 th:attr="aria-label=#{form.email.label}"> 5 6<!-- Conditional messages --> 7<div th:if="${itemCount > 0}"> 8 <p th:text="#{cart.items(${itemCount})}"></p> 9</div> 10<div th:if="${itemCount == 0}"> 11 <p th:text="#{cart.empty}"></p> 12</div> 13 14<!-- Inline message syntax --> 15<p> 16 The price is [[#{product.price(${price})}]] 17</p> 18 19<!-- Message in JavaScript --> 20<script th:inline="javascript"> 21 const errorMessage = /*[[#{error.server_error}]]*/ 'Default error'; 22 console.error(errorMessage); 23</script>
Form with Validation Messages
HTML1<form th:action="@{/register}" th:object="${userForm}" method="post"> 2 <div> 3 <label th:text="#{form.email.label}">Email</label> 4 <input type="email" 5 th:field="*{email}" 6 th:errorclass="error"> 7 <span th:if="${#fields.hasErrors('email')}" 8 th:errors="*{email}" 9 class="error-message"> 10 Email error 11 </span> 12 </div> 13 14 <div> 15 <label th:text="#{form.password.label}">Password</label> 16 <input type="password" 17 th:field="*{password}" 18 th:errorclass="error"> 19 <span th:if="${#fields.hasErrors('password')}" 20 th:errors="*{password}" 21 class="error-message"> 22 Password error 23 </span> 24 </div> 25 26 <button type="submit" th:text="#{button.submit}">Submit</button> 27</form>
Validation Message i18n
Bean Validation (JSR-380) messages can be localized through MessageSource.
Entity with Validation
JAVA1package com.example.app.model; 2 3import jakarta.validation.constraints.*; 4 5public class UserRegistrationForm { 6 7 @NotBlank(message = "{validation.email.notblank}") 8 @Email(message = "{validation.email.invalid}") 9 private String email; 10 11 @NotBlank(message = "{validation.password.notblank}") 12 @Size(min = 8, message = "{validation.password.minlength}") 13 @Pattern( 14 regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=]).*$", 15 message = "{validation.password.pattern}" 16 ) 17 private String password; 18 19 @NotBlank(message = "{validation.name.notblank}") 20 @Size(min = 2, max = 50, message = "{validation.name.length}") 21 private String name; 22 23 // Getters and setters 24}
ValidationMessages Properties
PROPERTIES1# ValidationMessages.properties (Default) 2validation.email.notblank=Email is required 3validation.email.invalid=Please enter a valid email address 4validation.password.notblank=Password is required 5validation.password.minlength=Password must be at least {min} characters long 6validation.password.pattern=Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character 7validation.name.notblank=Name is required 8validation.name.length=Name must be between {min} and {max} characters 9 10# ValidationMessages_es.properties (Spanish) 11validation.email.notblank=El correo electrónico es obligatorio 12validation.email.invalid=Por favor, introduce una dirección de correo electrónico válida 13validation.password.notblank=La contraseña es obligatoria 14validation.password.minlength=La contraseña debe tener al menos {min} caracteres 15validation.password.pattern=La contraseña debe contener al menos una letra mayúscula, una letra minúscula, un número y un carácter especial 16validation.name.notblank=El nombre es obligatorio 17validation.name.length=El nombre debe tener entre {min} y {max} caracteres
Controller with Validation
JAVA1@Controller 2public class RegistrationController { 3 4 @GetMapping("/register") 5 public String showForm(Model model) { 6 model.addAttribute("userForm", new UserRegistrationForm()); 7 return "register"; 8 } 9 10 @PostMapping("/register") 11 public String submitForm( 12 @Valid @ModelAttribute("userForm") UserRegistrationForm form, 13 BindingResult result, 14 Model model 15 ) { 16 if (result.hasErrors()) { 17 // Errors are automatically localized 18 return "register"; 19 } 20 21 // Process registration 22 userService.register(form); 23 24 String successMessage = messageSource.getMessage( 25 "registration.success", 26 null, 27 LocaleContextHolder.getLocale() 28 ); 29 30 model.addAttribute("successMessage", successMessage); 31 return "redirect:/login"; 32 } 33}
Custom Message Formats
Using Java MessageFormat
Spring Boot supports MessageFormat placeholders for complex formatting.
PROPERTIES# messages.properties order.summary=Order #{0} placed on {1, date, long} for {2, number, currency}
JAVA1String message = messageSource.getMessage( 2 "order.summary", 3 new Object[]{12345, new Date(), 99.99}, 4 LocaleContextHolder.getLocale() 5); 6// Output (en): Order #12345 placed on January 15, 2026 for $99.99 7// Output (es): Pedido #12345 realizado el 15 de enero de 2026 por 99,99 €
Custom MessageSource Implementation
JAVA1public class DatabaseMessageSource extends AbstractMessageSource { 2 3 @Autowired 4 private TranslationRepository translationRepository; 5 6 @Override 7 protected MessageFormat resolveCode(String code, Locale locale) { 8 Translation translation = translationRepository 9 .findByKeyAndLanguage(code, locale.getLanguage()); 10 11 if (translation != null) { 12 return new MessageFormat(translation.getValue(), locale); 13 } 14 15 return null; 16 } 17}
IntlPull Integration
Integrate IntlPull with Spring Boot for centralized translation management.
Add IntlPull Dependency
XML1<dependency> 2 <groupId>com.intlpull</groupId> 3 <artifactId>intlpull-spring-boot-starter</artifactId> 4 <version>1.0.0</version> 5</dependency>
Configuration
YAML1# application.yml 2intlpull: 3 api-key: ${INTLPULL_API_KEY} 4 project-id: your-project-id 5 sync-interval: 3600 # Sync every hour 6 cache-enabled: true 7 fallback-locale: en
Auto-Sync Configuration
JAVA1package com.example.app.config; 2 3import com.intlpull.spring.IntlPullMessageSource; 4import org.springframework.context.annotation.Bean; 5import org.springframework.context.annotation.Configuration; 6 7@Configuration 8public class IntlPullConfig { 9 10 @Bean 11 public IntlPullMessageSource intlPullMessageSource() { 12 IntlPullMessageSource messageSource = new IntlPullMessageSource(); 13 messageSource.setApiKey(System.getenv("INTLPULL_API_KEY")); 14 messageSource.setProjectId("your-project-id"); 15 messageSource.setSyncInterval(3600); 16 messageSource.setCacheEnabled(true); 17 return messageSource; 18 } 19}
CLI Workflow
Terminal1# Extract keys from messages.properties 2$ intlpull extract \ 3 --source src/main/resources \ 4 --pattern "messages*.properties" \ 5 --format properties 6 7# Push keys to IntlPull 8$ intlpull push --file messages.properties 9 10# Translators work in IntlPull UI 11 12# Pull translated files 13$ intlpull pull --format properties --output src/main/resources 14 15# Files generated: 16# - messages_es.properties 17# - messages_fr.properties 18# - messages_de.properties
Testing i18n
JAVA1package com.example.app; 2 3import org.junit.jupiter.api.Test; 4import org.springframework.beans.factory.annotation.Autowired; 5import org.springframework.boot.test.context.SpringBootTest; 6import org.springframework.context.MessageSource; 7 8import java.util.Locale; 9 10import static org.junit.jupiter.api.Assertions.assertEquals; 11 12@SpringBootTest 13class MessageSourceTest { 14 15 @Autowired 16 private MessageSource messageSource; 17 18 @Test 19 void testEnglishMessage() { 20 String message = messageSource.getMessage( 21 "app.welcome", 22 new Object[]{"John"}, 23 Locale.ENGLISH 24 ); 25 assertEquals("Welcome, John!", message); 26 } 27 28 @Test 29 void testSpanishMessage() { 30 String message = messageSource.getMessage( 31 "app.welcome", 32 new Object[]{"John"}, 33 new Locale("es") 34 ); 35 assertEquals("¡Bienvenido, John!", message); 36 } 37 38 @Test 39 void testPluralMessage() { 40 String message = messageSource.getMessage( 41 "cart.items", 42 new Object[]{5}, 43 Locale.ENGLISH 44 ); 45 assertEquals("5 items in your cart", message); 46 } 47 48 @Test 49 void testMissingKey() { 50 String message = messageSource.getMessage( 51 "nonexistent.key", 52 null, 53 "Default message", 54 Locale.ENGLISH 55 ); 56 assertEquals("Default message", message); 57 } 58}
Frequently Asked Questions
Q: Should I use properties or YAML files for messages?
Use properties files. Spring Boot's MessageSource is built for .properties format. YAML doesn't support the same file naming convention (messages_es.properties).
Q: How do I handle message updates without restarting the app?
Use ReloadableResourceBundleMessageSource with a cache timeout. Or integrate IntlPull's auto-sync feature for live updates.
Q: Can I use UTF-8 characters in properties files?
Yes, but set spring.messages.encoding=UTF-8. Older Java versions required native2ascii conversion, but modern Spring Boot handles UTF-8 natively.
Q: How do I localize date, number, and currency formats?
Use Java's DateTimeFormatter, NumberFormat, and MessageFormat with the current locale. Spring Boot handles this automatically in templates.
Q: What's the difference between MessageSource and ResourceBundle?
MessageSource is Spring's abstraction over ResourceBundle with additional features: parameter substitution, locale resolution, and Spring integration.
Q: How do I handle missing translations?
Set spring.messages.fallback-to-system-locale=false to always fall back to default properties file. Or use IntlPull's validation to prevent missing translations.
Q: Can I have multiple MessageSource basenames?
Yes: spring.messages.basename=messages,errors,validation will load from multiple property file sets.
Q: How does IntlPull help with Spring Boot i18n?
IntlPull provides centralized translation management, CLI tools for sync, validation, translator collaboration, and live updates—all compatible with Spring Boot's MessageSource.
Conclusion
Spring Boot provides robust i18n support through:
- MessageSource for centralized message resolution
- LocaleResolver for flexible locale detection
- Thymeleaf integration with
#{...}syntax - Validation message localization with Bean Validation
- REST API i18n with locale headers
Key takeaways:
- Use
messages.propertiesfor default locale - Configure LocaleResolver based on your needs (session, cookie, or database)
- Leverage Thymeleaf's
#{}syntax for template i18n - Localize validation messages with
ValidationMessages.properties - Integrate IntlPull for centralized translation management
Spring Boot's i18n features combined with tools like IntlPull enable building fully localized applications with minimal code and maximum flexibility.
