IntlPull
Tutorial
13 min read

Spring Boot i18n: Complete Java Localization Guide for 2026

Master Spring Boot internationalization: MessageSource configuration, Thymeleaf #{} syntax, REST API localization, validation messages, LocaleResolver, and IntlPull integration.

IntlPull Team
IntlPull Team
Feb 12, 2026
On this page
Summary

Master Spring Boot internationalization: MessageSource configuration, Thymeleaf #{} syntax, REST API localization, validation messages, LocaleResolver, and IntlPull integration.

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

XML
1<!-- 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

JAVA
1// 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

JAVA
1package 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

PROPERTIES
1# 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
PROPERTIES
1# 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
PROPERTIES
1# 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.

JAVA
1@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.

JAVA
1@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.

JAVA
1@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.

JAVA
1public 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

JAVA
1package 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

JAVA
1package 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

HTML
1<!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

HTML
1<!-- 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

HTML
1<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

JAVA
1package 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

PROPERTIES
1# 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

JAVA
1@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}
JAVA
1String 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

JAVA
1public 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

XML
1<dependency>
2    <groupId>com.intlpull</groupId>
3    <artifactId>intlpull-spring-boot-starter</artifactId>
4    <version>1.0.0</version>
5</dependency>

Configuration

YAML
1# 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

JAVA
1package 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

Terminal
1# 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

JAVA
1package 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:

  1. MessageSource for centralized message resolution
  2. LocaleResolver for flexible locale detection
  3. Thymeleaf integration with #{...} syntax
  4. Validation message localization with Bean Validation
  5. REST API i18n with locale headers

Key takeaways:

  • Use messages.properties for 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.

Tags
spring-boot
java
i18n
localization
thymeleaf
backend
web-framework
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.