Internationalization (i18n)
Internationalization (i18n) means designing your application so it can present text in different languages without code changes. Spring Boot makes this declarative: you keep translated strings in resource bundles (messages_xx.properties), look them up through a MessageSource, and let a LocaleResolver decide which language a given request should use. The same bundles also localize Bean Validation error messages.
Message bundles
Spring Boot auto-configures a MessageSource backed by messages.properties files on the classpath. Create one file per locale under src/main/resources:
# messages.properties (default / fallback)
greeting=Hello
cart.items=You have {0} items in your cart
# messages_es.properties (Spanish)
greeting=Hola
cart.items=Tienes {0} artículos en tu carrito
# messages_fr.properties (French)
greeting=Bonjour
cart.items=Vous avez {0} articles dans votre panier
Tune the base name and encoding if needed:
spring:
messages:
basename: messages
encoding: UTF-8
fallback-to-system-locale: false
Tip: Set
fallback-to-system-locale: falseso an unknown locale falls back tomessages.properties(your chosen default) rather than the server’s OS locale, which makes behavior reproducible across environments.
Looking up messages in code
Inject the MessageSource and resolve keys against the current locale.
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Locale;
@RestController
public class GreetingController {
private final MessageSource messages;
public GreetingController(MessageSource messages) {
this.messages = messages;
}
@GetMapping("/greeting")
public String greeting(Locale locale) {
return messages.getMessage("greeting", null, locale);
}
@GetMapping("/cart")
public String cart(@RequestParam int count) {
Locale locale = LocaleContextHolder.getLocale();
return messages.getMessage("cart.items", new Object[]{count}, locale);
}
}
The {0} placeholder in the bundle is filled from the Object[] array — these are parameterized messages backed by java.text.MessageFormat.
Output:
GET /greeting (Accept-Language: es) -> Hola
GET /cart?count=3 (Accept-Language: fr) -> Vous avez 3 articles dans votre panier
Spring injects the resolved Locale directly as a controller method parameter, or you can read it anywhere via LocaleContextHolder.getLocale().
Choosing the locale: LocaleResolver
A LocaleResolver determines which locale applies to each request. Spring MVC’s default is AcceptHeaderLocaleResolver, which reads the browser’s Accept-Language header.
| Resolver | Source of locale | Stateless? | Can change at runtime? |
|---|---|---|---|
AcceptHeaderLocaleResolver (default) | Accept-Language header | Yes | No (fixed per request) |
SessionLocaleResolver | HTTP session attribute | No | Yes |
CookieLocaleResolver | Cookie | Mostly | Yes |
FixedLocaleResolver | Single hard-coded locale | Yes | No |
To let users pick and persist a language, switch to a SessionLocaleResolver:
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
import java.util.Locale;
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver resolver = new SessionLocaleResolver();
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
Switching language with LocaleChangeInterceptor
The LocaleChangeInterceptor watches for a request parameter (e.g. ?lang=es) and updates the resolver’s stored locale. Register it on the MVC config:
import org.springframework.context.annotation.*;
import org.springframework.web.servlet.config.annotation.*;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
Now a request to /greeting?lang=fr switches the session locale to French, and subsequent requests in that session stay French until changed again.
Note:
LocaleChangeInterceptorrequires a mutable resolver such asSessionLocaleResolverorCookieLocaleResolver. It cannot change the immutableAcceptHeaderLocaleResolver.
Localizing validation messages
Bean Validation messages also flow through the MessageSource. First wire the validator to use it:
import jakarta.validation.Validator;
import org.springframework.context.MessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
@Bean
public LocalValidatorFactoryBean getValidator(MessageSource messageSource) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource);
return bean;
}
Reference a message key (in braces) from the constraint:
import jakarta.validation.constraints.*;
public record RegisterRequest(
@NotBlank(message = "{user.name.required}") String name,
@Email(message = "{user.email.invalid}") String email
) {}
Add the keys to each bundle:
# messages.properties
user.name.required=Name is required
user.email.invalid=Email address is not valid
# messages_es.properties
user.name.required=El nombre es obligatorio
user.email.invalid=La dirección de correo no es válida
Now a validation failure returns the message in the request’s locale. See Validation Introduction and Handling Validation Errors for how those messages surface in responses.
Best Practices
- Always provide a complete default
messages.propertiesas the fallback bundle. - Use UTF-8 encoding and
fallback-to-system-locale: falsefor predictable resolution. - Prefer parameterized messages over string concatenation so word order stays correct per language.
- Use a
SessionLocaleResolverorCookieLocaleResolverplusLocaleChangeInterceptorto let users choose a language. - Externalize all user-facing text — including validation messages — into bundles, never inline literals.