Illustration de l'article

Multilingual articles with ROQ

Because writing in French is great, but being read in English is not bad either

18 02 2026 6 min de lecture
🌐 Languages:

As you may have noticed, this blog contains articles (mostly in) French and some in English.

Because making a choice is too hard for you, honey?
— Passive/aggressive reader
From the other side of the fourth wall

As the subtitle of this article suggests, I like writing in French. First because it is my mother tongue, where I feel comfortable and accurate, and second because I find there isn’t enough quality content in french in our field.

But I’m not fooling myself, a vast majority of readers do not read French. Not blaming anyone here. So we need to find a way to write articles in multiple languages, but without degrading the user experience:

  • I don’t want an article written in multiple languages to appear multiple times in the article list.

  • I want indexing to take all versions into account.

  • Automatically detect the presence of another language on an article and provide a switch for the reader. Like this:

    lang switch

And off we go!

Articles written in only one language are in the post collection by default.

One collection per language

So we are going to create a second collection (that is, a second folder under the content directory), in my case, the second collection will be post-en. If you want to provide a third language, create a third collection.

Articles present in this collection are generated correctly, but they are not listed in the default home page (that’s what we want) and are therefore difficult to access(that’s not what we want).

Necessary attributes on articles

To link the different translations of the same article, we will use 2 frontmatter attributes:

  • key: This is the identifier of the article which allows us to identify the different versions of the same article.

  • lang: This is - you guessed it - the attribute that allows me to determine the language of the article.

Display

To display the switch we will create a dedicated qute tag:

templates/tags/i18n.html
{@io.quarkiverse.roq.frontmatter.runtime.model.Page page}

{#if page.hasTranslations()} (1)
<div class="language-switcher">
    <div class="language-switcher-label">
        <span>🌐 Languages:</span>
    </div>
    <div class="language-switcher-options">
        {#for language in page.translations()} (2)
        <a href="{language.url}" class="language-link">{language.flag} {language.code.toUpperCase()}</a> (3)
        {/for}
    </div>
</div>
{/if}
1 If there are translations for this page
2 For each translation
3 We display the link

The separation of concerns commands me to create a tag with very little responsibility, only display. All the logic is carried TemplateExtensions which allows us to add virtual methods on the io.quarkiverse.roq.frontmatter.runtime.model.Page object: hasTranslations, translations

The TemplateExtensions

We will start by modeling a translation:

public record Translation(String languageCode, String flag, RoqUrl url) {(1)(2)(3)
    static Translation fromPage(Page page) {
        String languageCode = page.data().getString(LANG, "fr")
           .toLowerCase().trim();
        return new Translation(languageCode, Language.fromCode(languageCode).flag(), page.url());
    }
}
1 A translation is composed of a language code, ie: fr, en
2 A flag: 🇫🇷 for example
3 The url of the document carrying this translation.

The 2 TemplateExtensions are defined as follows:

@TemplateExtension
public static boolean hasTranslations(Page page) {(1)
    return getRelatedPages(page)
        .findAny()
        .isPresent();
}

@TemplateExtension
public static List<Translation> translations(Page page) {(2)
    return getRelatedPages(page)
            .map(Translation::fromPage)
            .toList();
}
1 This one is trivial
2 Retrieves all related pages and transforms them into a list of `Translation`s

And finally the only code that really interests you, the getRelatedPages method:

private static Stream<Page> getRelatedPages(Page page) {
    String translationId = page.data().getString("key");
    if (translationId == null) { (1)
        return Stream.of();
    }
    return page.site().allPages() (2)
            .stream()
            .filter(doc -> translationId.equals(doc.data("key"))) (3)
            .filter(doc -> !doc.equals(page)) (4)
            .filter(distinctByKey(Page::id)); (5)
}
1 If there is no join key, no point continuing
2 Iterate over ALL pages of the site
3 We keep only the pages whose key corresponds
4 We reject the page on which we are already navigating
5 And we deduplicate
The complete class code
import io.quarkiverse.roq.frontmatter.runtime.model.Page;
import io.quarkiverse.roq.frontmatter.runtime.model.RoqUrl;
import io.quarkus.qute.TemplateExtension;

import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Stream;

/**
 * Template extension for the multilingual posts.
 * Provides methods to access multilingual information from Qute templates.
 */
public class I18NTemplateExtension {

    private static final String LANG = "lang";
    private static final String DOCUMENT_KEY = "key";

    /**
     * Checks if the given page has translations available.
     *
     * @param page the page to check
     * @return <code>true</code> if the page has translations, <code>false</code> otherwise
     */
    @TemplateExtension
    public static boolean hasTranslations(Page page) {
        return getRelatedPages(page).findAny().isPresent();
    }

    /**
     * Returns the list of available languages for the given page.
     * Each language object contains the language code, flag emoji, and document URL.
     *
     * @param page the page to get languages for
     * @return a list of language instance, or an empty list if no multilingual data is available
     */
    @TemplateExtension
    public static List<Translation> translations(Page page) {
        return getRelatedPages(page)
                .map(Translation::fromPage)
                .toList();
    }

    /**
     * Helper method to extract multilingual data from a page.
     *
     * @param page the page to extract data from
     * @return the multilingual data object, or null if not available
     */
    private static Stream<Page> getRelatedPages(Page page) {
        String translationId = page.data().getString(DOCUMENT_KEY);
        if (translationId == null) {
            return Stream.of();
        }
        return page.site().allPages()
                .stream()
                .filter(doc -> translationId.equals(doc.data(DOCUMENT_KEY)))
                .filter(doc -> !doc.equals(page))
                .filter(distinctByKey(Page::id));
    }

    private static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
        Set<Object> seen = ConcurrentHashMap.newKeySet();
        return t -> seen.add(keyExtractor.apply(t));
    }

    public record Translation(String code, String flag, RoqUrl url) {
        static Translation fromPage(Page page) {
            String languageCode = page.data().getString(LANG, "fr").toLowerCase().trim();
            return new Translation(languageCode, Language.fromCode(languageCode).flag(), page.url());
        }
    }
}

URL

I’ve choosen to customize the translated post urls. By default, they would have been https://<url_du_blog>/post-en/<article>; (following the collection name).

I’d rather have https://<url_du_blog>/post/<lang>/<article>;, and that’s really easy using frontmatter attibutes: link: /posts/en/:slug/

Wrap up

As you can see, it’s not very complicated, but for it to work, it requires duplicating your images, or placing them all in the public folder.

With a little bit of chance, all of this will soon be directly included in ROQ 😉.

author-jtama

Jérôme Tama

Techlead/Architecte/Compagnon du devoir