Localization Is Here! How I develop localization in Astro
Published at Jan 25, 2025 | Last updated on Jan 28, 2025
Finally after grinding for days, hours, minutes, bla bla bla and any other dramatic words. I managed to finished the localization feature of this personal website!
One roadmap task, taken down. Now there’s something intresting that I want to share with you, something that I learned, and encountered when trying to implement this feature.
The Astro Internationalization
It all started with integrating the built-in Astro i18n feature. I was following what the documentation said, if you are interested check them out: Internationalization Docs.
The Astro Internationalization feature is a routing-based i18n. It means they provided a way to create a different route for each language but not the translation itself. In the end, you need to implement the translation yourself. Luckily, there’s a recipe for that in the documentation: Adding i18n Feature.
The recipe is what I followed to implement the translation. So throughout this post, I will share with you how I implement the translation.
Setting Up
The first thing to do is to configure the i18n in the astro.config.mjs
file by filling the i18n
object:
export default defineConfig({ // ... other config i18n: { locales: ['en', 'id'], defaultLocale: 'en', },})
Continue onward, move structure the pages on the src/pages
directory according to the locales.
The structure should be like this:
src/ pages/ [lang]/ index.astro other.astro index.astro 404.astro
Note
The i18n
actually have routing.prefixDefaultLocale
, I suggest to set this to true
or left as default, as setting it to false
will
make things more complicated. You want to avoid conditionals on your code:
if(url.pathname.includes('en')) { // do not add prefix}
Example case is when you want to create a change language button, you would have to configure the redirect with conditionals like above. You also won’t be able to use structure like above too.
As you can see in the Documentation, Astro i18n require you to split your routes based on the language. For example, if you have a blog page
you need to create a different route for each language, say /en/blog
and /id/blog
. Now you can just copy your file and translate it,
but it will resulting in a lot of duplicated code. That aside, I don’t have any benefit from it since most of the content on my website based on
even smaller components.
Therefore I decided to use a dynamic route generationn approach with getStaticPaths
. This way I can just create a single file and generate
the routes based on the language that I have. Here’s how I did it:
export function getStaticPaths() { return LANGUAGES.map((lang) => { return { params: { lang, }, } })}
Tip
I suggest to turn the code above to a helper function, so you can reuse it in other pages. This way you can just import the function and
call it in the getStaticPaths
function.
The LANGUAGES
is an array of language that I have, in this case ['en', 'id']
. This will generate the routes /en
and /id
. Now I can just
create a single file and use the lang
parameter to determine the language of the content.
Now each of the solution actually have its own pros and cons, for example by copying the file I can have a different layout for each language. This is perfect if your translation string are somewhat too long and caused an overflow. You can then simply adjust the class.
However the cons are if the most of the code be the same (just changing text), then you would have to maintain two files. Because of that I decided to use the dynamic approach instead as it is more maintainable for me. In the end, I did make some adjustment to ensure each view is still readable and not overflow on other language.
Technically, we can still use the dynamic approach to configure the layout by checking the lang
params and adjust the class accordingly. It’s all
a matter of preference. But if the layout is too different, I suggest to just copy the file.
URL Healing
After setting up the routes, we still have important thing to do. URL Healing. This will allow your visitor to visit any url without including the locale, once they visited this page, our website will redirect them to an URL with the locale included. This is important to ensure that the website still working apart from not having the locale set in the first place.
To do that, we will need to catch every other routes by simply putting file [...otherLocales]
:
src/ pages/ [lang]/ index.astro other.astro [...otherLocales].astro index.astro 404.astro
There we will configure in the Astro Script to redirect our user based on the default locale:
import type { GetStaticPaths } from "astro";import { getCollection } from "astro:content";
export const getStaticPaths = (async () => { const paths = ['/', '/other'];
return paths.map((path) => { return { params: { otherLocales: path, }, props: { redirectPath: path, } }; });}) satisfies GetStaticPaths;---
<meta http-equiv="refresh" content={`0;url=/en${Astro.props.redirectPath}`} />
The code contains paths
variable which is an array of paths that we should catch because you still want the website to return 404 if the path is not found.
The redirectPath
is the path that we want to redirect to. The meta
tag is used to redirect the user to the correct path. The 0
in the content
attribute
is the time in seconds before the redirect happens. You can adjust it to your liking.
With that the redirect is done, of course with SSR you can use server response. But since I am not enabling SSR, I decided to use the meta tag instead.
On the meta http-equiv, you can actually use Astro.preferredLocale
and replace the en
with it. I suggest you to check if the preferred locale is in the LANGUAGES
first before replacing it. This way you can ensure that the user will be redirected to the correct locale. Alternatively, try to implement Astro.preferredLocaleList
as a fallback if the preferred locale is not in the LANGUAGES
. Keep in mind that both Astro.prefferedLocale
and Astro.preferredLocaleList
are not available
in pre-rendered context, you would need On Demand Rendering. Basically SSR.https://docs.astro.build/en/guides/on-demand-rendering/
The Translation
Now that the routes are set, we can move forward to the translation. The translation itself is pretty simple, I just create a bunch of helpers function according to the recipe with some modifications to suit my needs.
First, define the languages to be supported:
export const LANGUAGES = ['en', 'id'] as const
You want them to be const
so you can use it as a type. Next, define the translation object:
export const translations = { en: { }, id: { },}
The key will be matching the content of LANGUAGES
. As for entries, I personally like to define the key in this convention
$page.$key
to make it easier to find the translation. For example, the translation for the blog page title would be blog.title
.
Next is to create the helper function to get the translation text, for this I decided to make a hooks-like function:
function getLang(url: URL) { const [, lang] = url.pathname.split('/')
if (LANGUAGES.includes(lang as SUPPORTED_LANGUAGES)) { return lang as T }
return LANGUAGES[0] as T}
export function useTranslation<T extends SUPPORTED_LANGUAGES>(url: URL) { const lang = getLang<T>(url)
const t = (key: keyof typeof translations[T]) => { return translations[lang][key] }
const getRelativeUrl = (path: string) => {...}
return { t, lang }}
Usage in Astro would be like this:
const {t} = useTranslation(Astro.url)
Why not take lang
directly as the parameter instead? It will work if your code contains getStaticPaths
in the Astro Scripts. However,
once you move to the Componnent which not have direct access to it, the Astro.params.lang
will be either undefined
or string
. This can
lead to a possible runtime error. Therefore, I decided to take the Astro.url
instead, since they will always be defined.
Now with that, enjoy the translation feature with Typescript support! You can use them on Astro Script or the View:
<h1>{t('blog.title')}</h1><p>{t('blog.description')}</p>
<slot />
Navigation
With content translated, the next thing to do is to create a navigation while keeping the locale in mind. With this, Astro actually provided a helper
getRelativeLocaleUrl
. However, I found their implementation
will have this trailing slash at the end when used. Example:
import { getRelativeLocaleUrl } from 'astro:i18n';
getRelativeLocaleUrl('en', 'blog') // /en/blog/
At first, I tried to search for Github Issues regarding this and found a way to just configure Trailing Slash.
However, this will affect the routing as well, if I set it to never
then url like /blog/
would return 404. Therefore, I decided to create my own helper function
that suit my needs:
export function useTranslation() { // ... rest of the useTranslation code
const getRelativeUrl = () => { const paths = [`/${lang}`]
if (path !== '/') { let normalizedPath = path.startsWith('/') ? path.substring(1) : path
if (normalizedPath.endsWith('/')) { normalizedPath = normalizedPath.substring(0, normalizedPath.length - 1) }
paths.push(normalizedPath) }
return paths.join('/') }
return {t, lang, getRelativeUrl}}
THe code is a bit of mess but get the job done. It behave similary like the getRelativeLocaleUrl
but without the trailing slash (I think). Usage would be like this:
const {getRelativeUrl} = useTranslation(Astro.url)
getRelativeUrl('blog') // /en/blog | automatically detect lang based on useTranslation code
Convenient right? I also didn’t have to pass lang as the function itself part of the useTranslation
function.
Creating the Language Switcher
With localization enabled, you might want to give user the ability to change the language. This is where the language switcher comes in. The language switcher is a simple component that will allow the user to change the language. Here’s how I did it:
<select class="lang-switcher" data-lang-switcher> <option selected={lang === "en"} value="en">English</option> <option selected={lang === "id"} value="id">Indonesia</option></select>
Notice that I am using data-lang-switcher
instead of id
. The reason is because this component are used more than one place in the website. So
using id which suppossed to be unique will cause a problem. With data-lang-switcher
I can use it multiple times without any problem.
The next thing is to bind the script…
const langSwitcher = document.querySelectorAll<HTMLSelectElement>( "select[data-lang-switcher]",);
langSwitcher.forEach((el) => el.addEventListener("change", (e) => { const selectedLang = el.value;
const url = new URL(location.href);
url.pathname = url.pathname.replace( /^\/[a-z]{2}/, `/${selectedLang}`, ); location.pathname = url.pathname; }),);
See how I used querySelectorAll
to ensure that all instance of the language switcher will be binded. The script will listen to the change
event
and then change the language based on the selected value. The script will then change the pathname of the URL to the selected language based on option
value.
Localized Blogs
Info
Before reading this, I recommended you to familiarize yourself with Astro Content Collections.
Now let’s move on to the blog page. The blog page is a bit different from the other pages, as it will have a list of dynamic blogs generated statically based on collections.
The getStaticPaths()
need to return the blog collections while prefixing them with the language, so the blog page will have a different route for each language.
Therefore, I need to adjust the getStaticPaths
function to include the blog pages:
export function getStaticPaths() { const blogPaths = blogs.flatMap((blog) => { return LANGUAGES.map((lang) => { return { params: { lang, slug: blog.slug, }, } }) })
return blogPaths}
This adjustment alone is not enough, as the blog content still remains the same, which is why I need to adjust the directory structure of the blogs to include the language.
src/ blogs/ en/ blog1.mdx blog2.mdx id/ blog1.mdx blog2.mdx
With the structure adjusted to above, the blog collection entry will return id like this en/blog1
and id/blog1
. Now we have 2 choices:
- Filter blog collection fetching to only include current specified locale
- Shows all the blog
I prefer to have my blogs all showed, because I can have a blog that only exist in one locale and another, I still want to let the user know that this blog title only exist on that other locale indicated by the Badge next to the date. So the user can still read it if they want to.
As for blog with multiple locale support, this is where I want to filter to only include the blog with current locale, as we have the translation available for that blog.
With the second approach no adjustment is neccessary, but if you were to pick the first approach you might want to remove the language from the url, for that you have to inject the language to the url generation.
<a href={getUrlWithoutLocale(blog.id)}>{blog.title}</a>
The getUrlWithoutLocale
is a helper function that will remove the locale from the url. Next, on the blog show view:
import { getEntry } from "astro:content";
const blog = await getEntry("blogs", `${Astro.params.lang}/${Astro.params.slug}`);
Just inject the current locale to the getEntry
function. With that, only localized blog will be shown.
Conclusion
Astro i18n is a nice base to start with, but it still requires some manual work to implement the translation. Using dynamic route generation allows me to have a single file for each page, which is more maintainable for me. The translation itself is simple, just create a translation object and a helper function to get the translation text. The language switcher is a simple component that allows the user to change the language. The blog page need to be adjusted as it will have a list of dynamic blogs generated statically based on collections.
After all, implementing localization in Astro is a fun experience. I hope this post can help you to implement localization in your Astro project.
Other Updates
Beyond localization I also bring some updates to this website including:
- Project Scroll Bar (now enabled as I notice some project get snapped)
- Bun v1.2 Upgrades (Now I use text-based lock file, so convenient!)
That’s all for now, see you in the next post!