Spring Boot

Last updated: August 04, 2025Author: Jakub Pomykała

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.

spring-boot-i18n

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
# 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:

  1. Add new languages in the Languages tab
  2. Use auto-translation to quickly translate into multiple languages
  3. Manually refine translations for better quality
  4. 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

Was this helpful?