Multilingual Ghost CMS Blog: 4. Making Tag Pages Multilingual
Let's go to the news tag page.
https://{host}/tag/news/

If you open that tag page, it shows everything: Korean posts (#ko: ko news1, ko news2), English posts (#en: en news1, en news2), and posts without a language tag assigned (Comming Soon).
Let's look at routes.yaml again.
routes:
collections:
/:
filter: "tag:en"
permalink: /{slug}/
template: index-en
/ko/:
filter: "tag:ko"
permalink: /ko/{slug}/
template: index-ko
taxonomies:
tag: /tag/{slug}/
author: /author/{slug}/
routes.yaml
I set the English and Korean post paths completely differently. (/, /ko/)
But you cannot do that for tags. A tag cannot have multiple paths, and it is currently set to /tag/{slug}/. In other words, multilingual handling has to be done on a single page.
Let's implement the tag page like this to support multiple languages.
- Show only posts with the language tag selected in the Language selector
- Change the visible posts when the language is changed in the Language selector
Change how the Language selector works
Right now, changing the Language selector value redirects to / or /ko/.
On the tag page, when the Language selector value changes, I will change it so that it does not redirect and instead shows only posts with the corresponding language tag.
Let's modify default.hbs so that the Language selector works differently depending on whether the page is a tag page or not.
{{ghost_foot}}
<script>
function initializeLanguageSelector() {
var selector = document.getElementById('languageSelector');
var currentUrl = window.location.href;
if (!currentUrl.includes('/tag/')) {
var urlLanguage = currentUrl.includes('/ko/') ? 'ko' : 'en';
localStorage.setItem('selectedLanguage', urlLanguage);
selector.value = urlLanguage;
} else {
selector.value = localStorage.getItem('selectedLanguage') || 'en';
}
selector.style.display = 'block';
selector.addEventListener('change', function() {
localStorage.setItem('selectedLanguage', this.value);
if (!currentUrl.includes('/tag/')) {
window.location.href = this.value === 'ko' ? '/ko/' : '/';
}
else {
window.location = window.location.href;
}
});
}
initializeLanguageSelector();
</script>- On lines 8 and 20, it checks whether the page is a tag page.
- if (!currentUrl.includes('/tag/'))
- Line 13
- If it is a tag page, it does not check the URL and instead takes the selectedLanguage value currently stored in localStorage as is and sets that as the Language selector value. If that value does not exist, for example when accessing the page directly by URL, it sets the default value en.
- selector.value = localStorage.getItem('selectedLanguage') || 'en';
- Line 24
- If it is a tag page, it does not redirect to /ko/ or / and simply refreshes the page.
- window.location = window.location.href;
The reason for refreshing is to show posts that match that language. Up to this point, applying this means that changing the Language selector value on the tag page will not redirect to /ko or /. However, it still will not show posts for that language. To do that, tag.hbs must be modified.
Modify tag.hbs
You can check the modified tag.hbs code at the link below.
The idea is as follows.
- Templates are processed on the server side. You cannot use the localStorage value held on the frontend to fetch only posts in a specific language.
- You have to use a method where you fetch everything first and then filter it.
- On the tag page, a post has the form <div class="post-card">. Let's also include language information in that div and then filter it on the frontend.
But the problem is that there is no way in the template layer to know which language tag that post has. To solve this, there is a rule in this tutorial that was established in the first post.
{{#foreach posts}}
<div class="post-card" data-language="{{tags.[1].name}}">
{{!-- The tag below includes the markup for each post - partials/post-card.hbs --}}
{{> "post-card"}}
</div>
{{/foreach}}If you get the second tag (tags.[1].name), you can pass that post's language through the template layer.
Now let's look at the frontend part. First, all post-cards need to be hidden. Set opacity to 0. I added line 5 to create a smooth fade-in effect for posts when the language is changed in the language selector.
<style>
.post-card-excerpt,
div.post-card {
opacity: 0; /* Initially set all post cards to be transparent */
transition: opacity 0.5s ease; /* Transition for smooth display */
}
</style>For posts that match the condition, set opacity to 1 so they become visible.
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectedLanguage = localStorage.getItem('selectedLanguage') || 'en'; // Read language setting from local storage or use default 'en'
const postCards = document.querySelectorAll('div.post-card[data-language]');
let visibleCount = 0;
postCards.forEach(card => {
// Remove '#' character and update language setting
const language = card.getAttribute('data-language');
const cleanedLanguage = language.replace('#', '');
card.setAttribute('data-language', cleanedLanguage);
// Check if it matches the selected language, then show or hide
if (cleanedLanguage !== selectedLanguage) {
card.style.display = 'none';
} else {
visibleCount++; // Count the number of posts that match the selected language
setTimeout(() => { // Use setTimeout for a smooth display
card.style.opacity = 1; // Set opacity to 1 to make the element visible
}, 10);
}
});
// Dynamically update text
const collectionText = document.querySelector('.post-card-excerpt');
if (collectionText) {
const text = visibleCount === 0 ? 'zero posts' :
visibleCount === 1 ? '1 post' :
`${visibleCount} posts`; // Set text based on the count
collectionText.innerHTML = `A collection of ${text}`; // Set text in HTML
setTimeout(() => { // Use setTimeout for a smooth display
collectionText.style.opacity = 1; // Set opacity to 1 to make the element visible
}, 10);
}
});
</script>- It gets the selectedLanguage value stored in localStorage, and if that value does not exist, it uses the default value 'en'.
- The language tags are #en or #ko. The selectedLanguage value is en or ko. For direct comparison, let's remove all # characters from the data-language attribute.
- If it does not match the condition, set display = 'none'. If it does match, change opacity = 1 to show the post.
- While processing this, it counts the visibleCount value and displays on the screen how many posts there are.
Result

It was implemented just as I wanted. I think there may be a more concise way to handle the CSS, but with my knowledge, this seems to be the best I can do for now.
Going forward
- Multilingual handling for the About and Author pages
- Clicking the logo in the top left always redirects to /
There is still a lot left to handle. However, these are not areas that have a major impact on usability, so I plan to fix them gradually.
