Speak My Language: Internationalization in Quarkus with Property Files, PostgreSQL, and Locale Switching
Learn how to build a modern, global-ready Java application using message bundles, dynamic translations from PostgreSQL, and Qute-powered UI language switching with Quarkus.
You’ve built your Quarkus app. It’s slick, reactive, blazing fast—and completely unintelligible to your German users. Not because they’re confused. Because it literally only speaks English.
Let’s fix that.
In this tutorial, you’ll learn how to make your Quarkus application multilingual using both property files and dynamic messages stored in a PostgreSQL database. We’ll also give your users the power to switch languages with a simple dropdown or link in the UI. All of this without losing your sanity—or your startup time.
Internationalization vs Localization: What’s the Difference?
Before jumping into code, let’s clarify the fundamentals. Internationalization (often abbreviated as i18n) refers to the design and development of software so that it can be adapted to various languages and regions without engineering changes. Localization (l10n), on the other hand, is the process of adapting software for a specific region or language by translating text and adjusting formats for date, currency, etc.
In a Quarkus context, i18n means enabling your application to serve localized content based on the user's preferences. This is typically handled through message bundles (properties files) or dynamically from a database, depending on your use case. You can follow along or just take a look at the Github repository with the running example application.
Prerequisites
Before you start, make sure you have the following installed and ready:
Java (JDK 17 or newer): Compatible with your Quarkus version.
Apache Maven: For building your Quarkus application.
Podman: For running Quarkus Dev Services (PostgresQL in this example)
Setting Up File-Based Message Bundles
Let’s start with the simplest and most common approach: using property files to provide localized messages. This is ideal for smaller apps or when translations don’t change often.
Creating the Project
You can generate a basic Quarkus project with Maven:
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=i18n-demo \
-Dextensions="rest,qute,hibernate-orm-panache,jdbc-postgresql"
quarkus-rest
Adds support for building RESTful web services using JAX-RS with Quarkus’ reactive core. Enables you to expose endpoints using annotations like@Path
,@GET
,@POST
, etc.quarkus-qute
Integrates the Qute templating engine into your application. Qute is a powerful, type-safe template engine used for generating dynamic HTML (or other text formats) with support for localization and reusable components.quarkus-hibernate-orm-panache
Adds Hibernate ORM with Panache, a developer-friendly API layer over JPA. It simplifies entity and repository code with conventions and utility methods, reducing boilerplate.quarkus-jdbc-postgresql
Provides the PostgreSQL JDBC driver and runtime integration. This allows your application to connect to a PostgreSQL database using traditional JDBC access patterns or ORM frameworks like Hibernate.
Navigate to the generated project directory:
cd i18n-demo
Defining Message Files
Under src/main/resources/messages/
, create the following property files:
AppMessages.properties
(default: English)AppMessages_de.properties
(German)AppMessages_es.properties
(Spanish)
Here’s an example of each:
AppMessages.properties
welcome=Welcome!
goodbye=Goodbye, {0}!
AppMessages_de.properties
welcome=Willkommen!
goodbye=Auf Wiedersehen, {0}!
AppMessages_es.properties
welcome=¡Bienvenido!
goodbye=Adiós, {0}!
These files follow Java's ResourceBundle
naming conventions and will be resolved automatically by Quarkus based on the user’s locale.
Injecting Localized Messages into Your Application
To use the messages defined in your property files, Quarkus provides the @MessageBundle
annotation, which links a Java interface to your message files.The basic idea is that every message is potentially a very simple template. In order to prevent type errors, a message is defined as an annotated method of a message bundle interface. Quarkus generates the message bundle implementation at build time.
Defining the Message Bundle Interface
Create a file named AppMessages.java
under src/main/java/org/acme/i18n/
.
package org.acme.i18n;
import io.quarkus.qute.i18n.Message;
import io.quarkus.qute.i18n.MessageBundle;
@MessageBundle("AppMessages")
public interface AppMessages {
@Message("Welcome!")
String welcome();
@Message("Goodbye, {0}!")
String goodbye(String name);
}
Note: the @MessageBundle("AppMessages") annotation links the message bundle to the resource files under src/main/resources/messages/.
Using the Messages in a REST Endpoint
Now let’s wire this into a JAX-RS resource. Create a new file GreetingResource.java
under src/main/java/org/acme/
.
package org.acme;
import org.acme.i18n.AppMessages;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/greet")
@Produces(MediaType.TEXT_PLAIN)
public class GreetingResource {
@Inject
AppMessages messages;
@GET
public String sayHello() {
return messages.welcome();
}
@GET
@Path("/bye/{name}")
public String sayGoodbye(@PathParam("name") String name) {
return messages.goodbye(name);
}
}
You can also inject a specific locale if needed:
@Inject
@Localized("de")
AppMessages germanMessages;
You can now start the application:
quarkus dev
And visit http://localhost:8080/greet
Using PostgreSQL for Dynamic Message Loading
While file-based i18n works well, it requires redeploying your app every time a translation changes. In enterprise scenarios, you may want to manage translations in a database and allow updates without restarting the service.
Let’s add PostgreSQL-backed message loading to our Quarkus application.
Creating the Database Table
You’ll need a table to store your messages. We’ll have Hibernate auto create it for now. But we need the content. add the below to the file import.sql
under src/main/resources/
.
INSERT INTO LocalizedMessage (id, messageKey, langTag, messageContent) VALUES
('1', 'db.welcome', 'en', 'Hello'),
('2','db.welcome', 'fr', 'Bonjour'),
('3','db.welcome', 'de', 'Hallo');
Add the following to the src/main/resources/application.properties
# Hibernate DevServices configuration
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=postgres
quarkus.datasource.password=postgres
# Making sure the tables are re-created with every start and the statements are logged
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
#Default locale for the application and supported locales
quarkus.default-locale=en
quarkus.locales=en,de,es
Defining the Entity and Repository
Create a file src/main/java/org/acme/i18n/LocalizedMessage.java
:
package org.acme.i18n;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@Entity
public class LocalizedMessage {
@Id
@GeneratedValue
public Long id;
public String messageKey;
public String langTag;
public String messageContent;
}
Then create src/main/java/org/acme/i18n/LocalizedMessageRepository.java
:
package org.acme.i18n;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;
import org.jboss.logging.Logger;
import java.util.Locale;
@ApplicationScoped
public class LocalizedMessageRepository implements PanacheRepositoryBase<LocalizedMessage, Long> {
private static final Logger LOG = Logger.getLogger(LocalizedMessageRepository.class);
public Optional<String> findMessage(String key, Locale locale) {
LOG.info("LOCALE: " + locale.toString());
Optional<LocalizedMessage> message = find("messageKey = ?1 and langTag = ?2", key, locale.toString())
.firstResultOptional();
if (message.isPresent())
return Optional.of(message.get().messageContent);
if (!locale.getCountry().isEmpty()) {
message = find("messageKey = ?1 and langTag = ?2", key, locale.getLanguage()).firstResultOptional();
if (message.isPresent())
return Optional.of(message.get().messageContent);
}
return Optional.empty();
}
}
Building the Message Service
Now let’s build the component that fetches messages dynamically at runtime.
Create src/main/java/org/acme/i18n/DatabaseMessageSource.java
:
package org.acme.i18n;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.text.MessageFormat;
import java.util.Locale;
@ApplicationScoped
public class DatabaseMessageSource {
private static final Logger LOG = Logger.getLogger(DatabaseMessageSource.class);
@Inject
LocalizedMessageRepository repository;
@Inject
CurrentRequestLocale requestLocale;
@ConfigProperty(name = "quarkus.default-locale", defaultValue = "en")
String defaultLocaleStr;
public String getMessage(String key, Object... args) {
return getMessage(key, requestLocale.get(), args);
}
public String getMessage(String key, Locale locale, Object... args) {
LOG.info("Fetching message for key: " + key + ", locale: " + locale);
Locale fallbackLocale = Locale.forLanguageTag(defaultLocaleStr);
String messageContent = repository.findMessage(key, locale)
.orElseGet(() -> repository.findMessage(key, fallbackLocale)
.orElse("!!" + key + "!!"));
return (args != null && args.length > 0)
? MessageFormat.format(messageContent, args)
: messageContent;
}
}
The DatabaseMessageSource
class is responsible for fetching localized messages from a repository based on the current request's locale or a specified locale. It provides a fallback mechanism to a default locale if the message is not found for the requested locale.
Dependency Injection:
Injects
LocalizedMessageRepository
to retrieve messages.Injects
CurrentRequestLocale
to determine the current locale.Uses a configuration property (
quarkus.default-locale
) for the default locale.
Message Retrieval:
getMessage(String key, Object... args)
: Fetches a message for the current request's locale.getMessage(String key, Locale locale, Object... args)
: Fetches a message for a specific locale, with fallback to the default locale.
Fallback Mechanism:
If a message is not found for the requested locale, it falls back to the default locale.
If still not found, it returns a placeholder (
!!key!!
).
Message Formatting:
Supports formatting messages with arguments using
MessageFormat
.
Now inject it into your src/main/java/org/acme/GreetingResource.java
@Inject
DatabaseMessageSource dbMessages;
@GET
@Path("/db-greet")
public String greetFromDb() {
return dbMessages.getMessage("db.welcome");
}
Locale Detection and Switching
Quarkus resolves the current locale automatically using the Accept-Language
HTTP header from the browser. But this might not be enough for your requirements. In this case you you need to implement your own mechanism for determining the current locale in your application.
Create a RequestScoped Locale Bean
If you want to make this injectable in services or template resolvers, wrap it in a custom bean src/main/java/org/acme/i18n/CurrentRequestLocale.java
package org.acme.i18n;
import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import java.util.Locale;
import org.jboss.logging.Logger;
@RequestScoped
public class CurrentRequestLocale {
private static final Logger LOG = Logger.getLogger(CurrentRequestLocale.class);
@Context
UriInfo uriInfo;
@Context
HttpHeaders headers;
public Locale get() {
String lang = uriInfo.getQueryParameters().getFirst("lang");
if (lang != null && !lang.isBlank()) {
LOG.info("Locale from query parameter: " + lang);
return Locale.forLanguageTag(lang);
}
List<Locale> locales = headers.getAcceptableLanguages();
if (!locales.isEmpty()) {
LOG.info("Locale from Accept-Language header: " + locales.get(0));
return locales.get(0);
}
return Locale.ENGLISH;
}
}
Now you can @Inject CurrentRequestLocale locale
anywhere in your app and call locale.get()
.
Let’s add it to the src/main/java/org/acme/i18n/DatabaseMessageSource.java
:
//...
@Inject
CurrentRequestLocale requestLocale;
/**
* Fetches a localized message for the current request's locale.
*/
public String getMessage(String key, Object... args) {
return getMessage(key, requestLocale.get(), args);
}
//..
Start the application:
quarkus dev
And visit http://localhost:8080/greet/db-greet?lang=de
You will be greeted in German.
Adding Locale Switching to Your UI
To allow users to switch languages via the UI, we’ll create a Qute template that provides a very straight forward language switching mechanism.
Place the following HTML content in src/main/resources/templates/index.html
:
<!DOCTYPE html>
<html lang="{locale.language}">
<head>
<meta charset="UTF-8">
<title>{AppMessages:welcome}</title>
</head>
<body>
<h1>{AppMessages:welcome}</h1>
<form method="get" action="/">
<label for="lang">Choose your language:</label>
<select name="lang" id="lang" onchange="this.form.submit()">
<option value="en" {#if locale.language == 'en'}selected{/if}>English</option>
<option value="de" {#if locale.language == 'de'}selected{/if}>Deutsch</option>
<option value="es" {#if locale.language == 'es'}selected{/if}>Español</option>
</select>
</form>
</body>
</html>
Note that we are using the AppMessages
bundle and the locale
object to determine the actual locale. The later is added as data payload to the qute template in the rest resource:
src/main/java/org/acme/IndexResource
:
package org.acme;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.qute.Template;
import java.util.Locale;
import org.acme.i18n.CurrentRequestLocale;
import org.jboss.logging.Logger;
@Path("/")
public class IndexResource {
private static final Logger LOG = Logger.getLogger(IndexResource.class);
@Inject
Template index;
@Inject
CurrentRequestLocale currentRequestLocale;
@GET
@Produces(MediaType.TEXT_HTML)
public String get() {
Locale locale = new Locale(currentRequestLocale.get().getLanguage());
LOG.info("Locale from IndexResource: " + locale);
return index.instance().setLocale(locale).data("locale", locale).render();
}
}
Unfortunately, Quarkus does not support using database backed properties in Qute templates (yet). The reason for this is, that the MessageBundles get resolved at build time and don’t allow this dynamic. If you need this, you would have to add the dynamic properties as payload before rendering the pages. You can also put complete Qute templates into a database. Take a look at this article to read more about reasons you would want to do this.
Start the application:
quarkus dev
And visit http://localhost:8080/?lang=de
You will be greeted in German.
Testing i18n Behavior
To test localization you can send requests with different Accept-Language
headers in your unit tests.
given()
.header("Accept-Language", "de")
.when()
.get("/greet")
.then()
.statusCode(200)
.body(equalTo("Willkommen!"));
To test the database version, ensure test data is loaded in your PostgreSQL instance and verify that fallback logic works as expected.
Best Practices for Quarkus i18n
Use consistent and descriptive keys like
user.login.failed
Always define a default locale fallback
Avoid embedding logic in messages
Consider caching dynamic messages with
@CacheResult
Keep bundles or database records well-structured and maintainable
Summary
You’ve now implemented full-featured i18n support in a Quarkus application using both static and dynamic strategies. Your users can switch languages manually, and you can update translations in real time without redeploying your app.
Want the full code? Take a look at my Github repository.