Back to all posts

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.

Previous Post
How I Built My Personal Website
Next Post
Disable PostHog in development