When building REST APIs for a global audience, you often want to serve error messages and responses in different languages — say, English and Japanese. Spring Boot has built-in support for i18n (internationalization): by combining MessageSource with LocaleResolver, you can switch messages based on the Accept-Language header.

This article walks through everything you need for a REST API implementation: creating messages.properties files, configuring LocaleResolver, and localizing @Valid validation error messages. The examples assume Spring Boot 3.x (Jakarta EE).

How i18n Works in Spring Boot

Spring Boot’s i18n implementation revolves around two components:

  • MessageSource: manages message text per language
  • LocaleResolver: resolves the locale (language) from the request

The LocaleResolver reads the Accept-Language: ja header from the request and stores the resolved locale in LocaleContextHolder. MessageSource then reads LocaleContextHolder.getLocale() to return the message in the appropriate language.

Creating messages.properties

Place your property files under src/main/resources:

src/main/resources/
├── messages.properties       # Default (fallback)
├── messages_ja.properties    # Japanese
└── messages_en.properties    # English

messages.properties acts as a fallback when the locale cannot be resolved.

# messages_ja.properties
user.name.required=ユーザー名は必須です
user.not.found=ユーザーが見つかりません
# messages_en.properties
user.name.required=User name is required
user.not.found=User not found

Specify the file location and encoding in application.properties. Set basename without the file extension (messages, not messages.properties). Always configure the encoding — omitting it will cause garbled Japanese text.

spring.messages.basename=messages
spring.messages.encoding=UTF-8
spring.messages.use-code-as-default-message=false

basename accepts multiple comma-separated values. To separate validation messages from general messages, write something like messages,validation.

When You Need to Define a MessageSource Bean

Auto-configuration is enabled by application.properties alone. If you need fine-grained control over cache duration, define a ReloadableResourceBundleMessageSource as a Bean:

@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource messageSource =
        new ReloadableResourceBundleMessageSource();
    messageSource.setBasenames("classpath:messages");
    messageSource.setDefaultEncoding("UTF-8");
    messageSource.setCacheSeconds(0); // Instant reload during development. Use -1 (permanent cache) in production.
    return messageSource;
}

setCacheSeconds(-1) is the default and means permanent cache (no file reloading). Use setCacheSeconds(0) during development if you want file changes to take effect immediately.

Defining your own @Bean disables Spring Boot’s auto-configuration (MessageSourceAutoConfiguration). The spring.messages.* settings in application.properties will no longer be read, so specify setBasenames and setDefaultEncoding directly in code.

Configuring LocaleResolver

AcceptHeaderLocaleResolver is ideal for REST APIs because it remains stateless. CookieResolver and SessionResolver persist the locale on the client or server side, making them suited for Web MVC — they introduce unnecessary state management for APIs.

@Configuration
public class WebConfig {

    @Bean
    public LocaleResolver localeResolver() {
        AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
        resolver.setDefaultLocale(Locale.JAPANESE);
        resolver.setSupportedLocales(List.of(Locale.JAPANESE, Locale.ENGLISH));
        return resolver;
    }
}

Use setDefaultLocale to specify the fallback language for requests without an Accept-Language header. Without this Bean registration, Spring MVC will not resolve the Accept-Language header correctly.

Retrieving Messages from MessageSource

In a service class, retrieve the locale via LocaleContextHolder.getLocale() and pass it to MessageSource:

@Service
@RequiredArgsConstructor
public class UserService {

    private final MessageSource messageSource;

    public User findUser(Long id) {
        return userRepository.findById(id).orElseThrow(() -> {
            String message = messageSource.getMessage(
                "user.not.found", null, LocaleContextHolder.getLocale()
            );
            return new UserNotFoundException(message);
        });
    }
}

If the message code does not exist, a NoSuchMessageException is thrown. To return a default value instead, pass a string as the third argument to getMessage(code, args, defaultMessage, locale).

Localizing @Valid Validation Error Messages

When using spring-boot-starter-validation, ValidationAutoConfiguration automatically injects MessageSource into LocalValidatorFactoryBean. This means you can manage @Valid validation error messages in messages.properties — just set the message attribute on the annotation using the {...} key format:

public class UserRequest {

    @NotBlank(message = "{user.name.required}")
    private String name;
}

Note that this automatic wiring is disabled if you define a custom ValidatorFactory Bean. For group validation with @Validated, see Spring Boot @Validated Usage and Group Validation.

Returning Localized Error Responses with @RestControllerAdvice

To convert MethodArgumentNotValidException into a localized error response, handle it with @RestControllerAdvice:

@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    private final MessageSource messageSource;

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationError(
            MethodArgumentNotValidException ex) {

        Locale locale = LocaleContextHolder.getLocale();

        List<String> messages = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> messageSource.getMessage(error, locale))
            .collect(Collectors.toList());

        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_ERROR", messages));
    }
}

The messageSource.getMessage(FieldError, Locale) overload resolves message codes held by the FieldError in order. For the basics of exception handling, see Spring Boot REST API Exception Handling Guide.

Testing with curl

# Japanese
curl -H "Accept-Language: ja" http://localhost:8080/api/users/999

# English
curl -H "Accept-Language: en" http://localhost:8080/api/users/999

# No header (default locale = Japanese is used)
curl http://localhost:8080/api/users/999
// Accept-Language: ja
{ "code": "NOT_FOUND", "message": "ユーザーが見つかりません" }

// Accept-Language: en
{ "code": "NOT_FOUND", "message": "User not found" }

Summary

With messages.properties in place, the AcceptHeaderLocaleResolver Bean registered, and messages retrieved through MessageSource, you have a clean and complete multilingual REST API. It’s straightforward to integrate into existing projects too — give it a try when you need to go global.