Type-Safe i18n in Astro Without External Packages
How I set up a Astro website with type-safe i18n without using any external packages or the Astro i18n out of the box support.
Astro provides i18n support out of the box. However it relies on sub-directories for translations so you would need something like this:
src/
└── pages/
├── en/
│ └── index.astro
└── nl/
└── index.astro
This approach is not ideal because you need to manually create the
sub-directories for each language meaning you have to duplicate the HTML code
for the index.astro
files.
The File Structure
For the file structure I used the following in the pages
directory a dynamic
route for the language. And a index.astro
file for redirecting to the default
language. A directory for the i18n specific code and a directory for the
translations.
src/
├── pages/
│ └── [...lang]/
│ └── index.astro
├── i18n/
│ ├── constants.ts
│ └── utils.ts
└── translations/
├── en.json
└── nl.json
translations
Here we’ll create translations for the website. The translations are organized in a JSON file for each language. Some examples:
{
"home": {
"title": "RubenSmn",
"description": "Welcome to my website",
"linkToAPage": "Go to a page"
}
}
{
"home": {
"title": "RubenSmn",
"description": "Welkom op mijn website",
"linkToAPage": "Ga naar een pagina"
}
}
i18n
This part contains the funtionlity to get the current language from the url and the translations.
constants.ts
First we need to import the translations and get some types from them.
// the `@` is a alias for the `src` directory
import enTranslation from "@/translations/en.json";
import nlTranslation from "@/translations/nl.json";
export type Translations = typeof enTranslation;
Now that we have our translations we can define some more information about all the locales we support.
export const locales = ["en", "nl"] as const;
export type Locale = (typeof locales)[number]; // "en" | "nl"
export const defaultLocale: Locale = "en";
Finally we can export the translations and the locales.
export const translations: Record<Locale, Translations> = {
en: enTranslation,
nl: nlTranslation,
};
utils.ts
The utils are a set of functions that help us with the locales and translations.
Let’s import all the things we need from our constants.ts
file.
import { defaultLocale, locales, translations, type Locale } from "./constants";
Now we need to be able to get the current locale from the url so we know which translations to use. If the locale from the url is not supported we return the default locale.
export function getLocaleFromUrl(url: string) {
const locale = url.split("/")[1] as Locale;
if (!locales.includes(locale)) return defaultLocale;
return locale;
}
To use the translations we’ll create a useTranslations
function that returns
the translations for the current locale.
export function useTranslations(locale?: Locale) {
return translations[locale || defaultLocale];
}
When we’re using a translations the urls should have the correct prefix for the current locale. This simple function will do that for us.
export function getUrlWithLocale(url: string, locale: Locale) {
return `/${locale}${url}`;
}
Okay awesome now that we got the functionality in place let’s use it in our
index.astro
file.
Astro pages
pages/[…lang]/index.astro
To create language-specific pages, we start by accessing the dynamic [lang]
route parameter. Astro makes this accessible via the Astro.params
object,
which retrieves URL parameters, and we’ll use getStaticPaths
to define our
language routes for static generation.
The lang
parameter is then passed to our useTranslations
function,
providing translations for the selected locale. We also include an undefined
path in getStaticPaths
, so localhost:4321/ loads the page
in the default locale instead of returning a 404 error.
---
import { useTranslations } from "@/i18n/utils";
import { defaultLocale, locales } from "@/i18n/constants";
let { lang } = Astro.params;
lang = lang || defaultLocale;
export function getStaticPaths() {
return [undefined, ...locales].map((locale) => ({
params: { lang: locale },
}));
}
const t = useTranslations(lang);
---
With this t
we can simply access all the translations we need.
<main>
<h1>{t.home.title}</h1>
<p>{t.home.description}</p>
</main>
Now we have the basic i18n setup done. When we visit the website, say the dev version, localhost:4321 we’ll see the default locale, the English translations. The same goes for localhost:4321/en/. When we visit localhost:4321/nl/ we get the Dutch translations.
Locale Switcher
So far so good. We can go further and add a locale switcher to the website. This can be done in any way you’d like, React, Vue, Svelte, just vanilla JS, etc. I’ll use a regualr Astro component with some very basic styling and a list for the sake of demonstration. You can expand on this the way you want.
The component will take in a lang
prop which is the current language. This
we’ll get from the params in the [lang]/index.astro
file and pass it down.
---
import type { Locale } from "@/i18n/constants";
// return the language name for the given locale
function getLanguageNameFromCode(locale: Locale) {
return new Intl.DisplayNames(locale, {
type: "language",
}).of(locale);
}
const currentPathname = Astro.url.pathname;
interface Props {
lang: Locale;
}
---
<ul>
{
locales.map((locale) => (
<li>
<a
href={`/${locale}${currentPathname}`}
style={{ color: locale === lang ? "green" : "black" }}
>
{getLanguageNameFromCode(locale)}
</a>
</li>
))
}
</ul>
Use the change locale component
In the [...lang]/index.astro
file we can now use the ChangeLocale
component.
---
// ...
import ChangeLocale from "@/components/ChangeLocale.astro";
// the already defined `lang` prop
let { lang } = Astro.params;
lang = lang || defaultLocale;
// ...
---
<ChangeLocale lang={lang} />
This allows us to change the language of the website by clicking the links while staying on the same page.
Final things
We now have all the basics ready and working. One final important step is to
ensure that when linking to other pages within our website, we include the
user’s selected language in the URL. We’ve already added a helper function,
getUrlWithLocale
, in our i18n/utils.ts
file to handle this. It’s crucial to
use this function when linking between pages to preserve the user’s language
choice throughout their experience on the site.
---
import { getUrlWithLocale } from "@/i18n/utils";
---
<a href={getUrlWithLocale("/link-to-a-page", lang)}>
{t.home.linkToAPage}
</a>
And that’s it. We now have a fully functional i18n setup with a locale switcher and we can use the translations very easily with the type safety.