Spring Boot i18n: Internationalization Setup Tutorial

Configure MessageSource, create locale-specific properties files, resolve locales, and render multilingual Thymeleaf templates — then automate translations with AI.

1

Add Dependencies

Spring Boot Starter Web includes MessageSource auto-configuration out of the box. Add Thymeleaf for server-rendered i18n templates, and the validation starter for localized error messages.

Spring Boot auto-configures a MessageSource bean that reads from messages.properties on the classpath. You only need explicit configuration if you want to customize the basename, encoding, or caching behavior.
pom.xml
<!-- pom.xml — Spring Boot Starter Web includes MessageSource auto-config -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Thymeleaf for server-side rendered templates with i18n -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- Validation (for localized error messages) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2

Configure MessageSource & LocaleResolver

Spring's MessageSource loads translations from .properties files using the basename convention: messages.properties (default), messages_de.properties (German), messages_ja.properties (Japanese). Configure a LocaleResolver to determine which locale to use per request.

Translation Files

messages.properties
# src/main/resources/messages.properties (default / English)
nav.home=Home
nav.about=About
nav.settings=Settings

greeting=Hello, {0}!
cart.itemCount={0,choice,0#No items|1#1 item|1<{0,number} items}

error.notFound=Page not found
error.serverError=Something went wrong. Please try again.

MessageSource Configuration

I18nConfig.java
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class I18nConfig {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource source =
            new ReloadableResourceBundleMessageSource();
        source.setBasename("classpath:messages");
        source.setDefaultEncoding("UTF-8");
        source.setCacheSeconds(3600); // reload interval in dev
        return source;
    }

    // Wire MessageSource into Bean Validation
    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource);
        return bean;
    }
}
If translations return the key name instead of the translated text, the most common cause is a wrong basename. The default is 'messages', which maps to messages.properties on the classpath. If your files are named differently or in a subdirectory, set spring.messages.basename explicitly.

Locale Resolution

Configure how Spring determines the active locale for each request. CookieLocaleResolver persists the user's choice across sessions. The LocaleChangeInterceptor lets users switch locales via a query parameter like ?lang=de.

LocaleConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 LocaleConfig implements WebMvcConfigurer {

    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
        resolver.setDefaultLocale(Locale.ENGLISH);
        resolver.setCookieMaxAge(3600 * 24 * 365); // 1 year
        return resolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
        interceptor.setParamName("lang"); // ?lang=de switches locale
        return interceptor;
    }

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

Use Translations in Code

Access translated messages in controllers via MessageSource injection, in Thymeleaf templates with the #{...} syntax, and in REST APIs using the auto-resolved Locale parameter.

Controller with MessageSource

HomeController.java
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.Locale;

@Controller
public class HomeController {

    private final MessageSource messageSource;

    public HomeController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping("/")
    public String home(Model model, Locale locale) {
        // Spring injects the resolved Locale automatically
        String greeting = messageSource.getMessage(
            "greeting",
            new Object[]{"World"},
            locale
        );
        model.addAttribute("greeting", greeting);
        return "home";
    }
}

Thymeleaf Templates

Thymeleaf's #{...} expression resolves message keys from your .properties files automatically. Pass parameters with #{key(arg0, arg1)} syntax. The template uses the locale resolved by your LocaleResolver.

home.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="#{nav.home}">Home</title>
</head>
<body>
    <!-- Simple message lookup -->
    <h1 th:text="#{greeting('World')}">Hello, World!</h1>

    <!-- Navigation with i18n -->
    <nav>
        <a href="/" th:text="#{nav.home}">Home</a>
        <a href="/about" th:text="#{nav.about}">About</a>
        <a href="/settings" th:text="#{nav.settings}">Settings</a>
    </nav>

    <!-- Parameterized messages -->
    <p th:text="#{cart.itemCount(3)}">3 items</p>

    <!-- Language switcher -->
    <div>
        <a th:href="@{/(lang=en)}">English</a>
        <a th:href="@{/(lang=de)}">Deutsch</a>
        <a th:href="@{/(lang=ja)}">日本語</a>
    </div>

    <!-- Conditional text based on locale -->
    <p th:if="${#locale.language == 'ja'}"
       th:text="#{greeting('ユーザー')}">
        こんにちは、ユーザーさん!
    </p>
</body>
</html>
Thymeleaf expressions like #{greeting('World')} pass arguments to MessageFormat. The static text inside HTML tags serves as a fallback when viewing the template without Spring — useful for designers working on templates directly.

REST API Localization

For REST APIs, Spring resolves the Locale from the Accept-Language header automatically. Inject it as a method parameter and pass it to MessageSource. Clients switch languages by sending different Accept-Language headers.

ApiController.java
import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Locale;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class ApiController {

    private final MessageSource messageSource;

    public ApiController(MessageSource messageSource) {
        this.messageSource = messageSource;
    }

    @GetMapping("/greeting/{name}")
    public ResponseEntity<Map<String, String>> greeting(
            @PathVariable String name,
            Locale locale) {  // Resolved from Accept-Language header
        String msg = messageSource.getMessage(
            "greeting", new Object[]{name}, locale
        );
        return ResponseEntity.ok(Map.of("message", msg));
    }

    // curl -H "Accept-Language: de" localhost:8080/api/greeting/Max
    // → {"message": "Hallo, Max!"}
}
REST APIs typically use AcceptHeaderLocaleResolver (header-based), while web apps use CookieLocaleResolver (cookie-based). If you serve both from the same app, consider a custom LocaleResolver that checks cookies first, then falls back to the Accept-Language header.

Bean Validation Messages

Spring automatically resolves validation constraint messages from your MessageSource. Use curly-brace placeholders like {validation.name.required} in your constraint annotations, and define the translations in your .properties files.

Bean Validation i18n
import jakarta.validation.constraints.*;

public class CreateUserRequest {

    @NotBlank(message = "{validation.name.required}")
    @Size(min = 2, max = 50, message = "{validation.name.size}")
    private String name;

    @Email(message = "{validation.email.invalid}")
    private String email;
}

// In messages.properties:
// validation.name.required=Name is required
// validation.name.size=Name must be between {min} and {max} characters
// validation.email.invalid=Please enter a valid email address
//
// In messages_de.properties:
// validation.name.required=Name ist erforderlich
// validation.name.size=Name muss zwischen {min} und {max} Zeichen lang sein
// validation.email.invalid=Bitte geben Sie eine gültige E-Mail-Adresse ein
4

Handle Plurals & Variables

Spring uses java.text.MessageFormat for interpolation and plurals. The ChoiceFormat pattern handles basic plural rules, but for full ICU plural support (Arabic's 6 forms, Russian's 3), add the ICU4J library.

MessageFormat Plurals
# MessageFormat plural syntax in messages.properties
# Uses java.text.ChoiceFormat — NOT ICU plural rules
cart.itemCount={0,choice,0#No items|1#1 item|1<{0,number} items}

# For more complex plurals, use ICU4J:
# 1. Add dependency: com.ibm.icu:icu4j
# 2. Use ICUMessageSource instead of ResourceBundleMessageSource
#
# Then you can write ICU-style plurals:
# cart.items={count, plural, one {# item} other {# items}}

# Variables with MessageFormat:
welcome.message=Welcome, {0}! You have {1,number} new {1,choice,1#notification|1<notifications}.
order.total=Order total: {0,number,currency}
event.date=Event date: {0,date,long}
ChoiceFormat is not the same as ICU plural rules. It uses numeric ranges (0#, 1#, 1<) rather than CLDR categories (zero, one, two, few, many, other). For languages with complex plural rules like Arabic, Polish, or Russian, ChoiceFormat is insufficient — use ICU4J's MessageFormat instead.

Automate Translations

With your i18n setup complete, translate your .properties files using AI. In your IDE, ask your AI assistant to translate your source file, or use the i18n Agent CLI in your CI/CD pipeline.

Terminal
# Translate your .properties files with AI
# In your IDE, ask your AI assistant:
> Translate src/main/resources/messages.properties to German, Japanese, and Spanish

✓ messages_de.properties created (1.2s)
✓ messages_ja.properties created (1.5s)
✓ messages_es.properties created (1.1s)

# Or use the CLI in CI/CD:
npx i18n-agent translate src/main/resources/messages.properties --lang de,ja,es
Translate incrementally — when you add new keys to messages.properties, translate just the new keys rather than regenerating all locale files. This preserves any human-reviewed translations in existing files.

Automate Translation Quality

Catch missing keys and broken placeholders before they ship with i18n-validate. Test your UI with pseudo-translations using i18n-pseudo before real translations arrive.

Zero-Config with spring-locale-chain

spring-locale-chain is an open-source Spring Boot starter that auto-configures LocaleResolver, LocaleChangeInterceptor, and supported-locale validation in a single dependency. Define your supported locales in application.yml and the library handles the rest.

pom.xml
<!-- Add spring-locale-chain for zero-config locale resolution -->
<dependency>
    <groupId>io.github.i18n-agent</groupId>
    <artifactId>spring-locale-chain</artifactId>
    <version>1.0.0</version>
</dependency>

Recommended File Structure

Project Structure
my-spring-app/
├── src/main/
│   ├── java/com/example/
│   │   ├── config/
│   │   │   ├── I18nConfig.java          # MessageSource bean
│   │   │   └── LocaleConfig.java        # LocaleResolver + interceptor
│   │   ├── controller/
│   │   │   └── HomeController.java      # Uses MessageSource
│   │   └── MyApplication.java
│   └── resources/
│       ├── messages.properties          # Default (English)
│       ├── messages_de.properties       # German
│       ├── messages_ja.properties       # Japanese
│       ├── messages_es.properties       # Spanish
│       ├── application.yml              # Spring config
│       └── templates/
│           └── home.html                # Thymeleaf with #{...}
├── pom.xml
└── build.gradle

Common Pitfalls

Non-ASCII Characters Display as Garbage

Java .properties files default to ISO-8859-1 encoding, not UTF-8. Characters like umlauts (ü) or CJK characters render as garbage. Fix: set spring.messages.encoding=UTF-8 in application.yml, or use Unicode escapes like \u00FC in your .properties files. Spring Boot's ReloadableResourceBundleMessageSource defaults to UTF-8, but ResourceBundleMessageSource does not.

ChoiceFormat Breaks for Non-English Plurals

Java's ChoiceFormat ({0,choice,0#|1#|1<}) only supports numeric ranges — it cannot express CLDR plural categories like 'few' or 'many'. Languages like Arabic (6 forms), Polish (3 forms), and Russian (3 forms) need ICU4J for correct pluralization. Do not assume ChoiceFormat handles all languages.

Translation Changes Not Reflected

ResourceBundleMessageSource caches bundles indefinitely by default. During development, use ReloadableResourceBundleMessageSource with cacheSeconds=0 to see changes without restarting. In production, set a reasonable cache duration (e.g., 3600 seconds) to balance performance and update speed.

Unexpected Fallback to JVM Locale

By default, Spring falls back to the JVM's default locale (Locale.getDefault()), not your messages.properties file. Set spring.messages.fallback-to-system-locale=false in application.yml to always use the default bundle. Otherwise, a server with JVM locale set to 'fr' will show French instead of English when a key is missing in the requested locale.

Try i18n Agent Now

Drop your translation file here

JSON, YAML, PO, XML, CSV, Markdown, Properties

or click to browse

Target languages

No signup requiredInstant estimate

Frequently Asked Questions