How to internationalize an AstroJS website while maintaining good SEO ?

We will see how to create an implementation of i18n with AstroJS
Wednesday, January 4, 2023

Preparation

Setup the languages and translations

First, we need to create a /src/i18n folder which contains the languages and the translations. Here is my current implementation:

/src/i18n/en.ts
export const EN: Array<{ id: string; translation: string }> = [
{ id: "hello", translation: "hello" },
{ id: "world", translation: "world" },
];
/src/i18n/fr.ts
export const FR: Array<{ id: string; translation: string }> = [
{ id: "hello", translation: "bonjour" },
{ id: "world", translation: "monde" },
];
/src/i18n/index.ts
export { EN } from "./en";
export { FR } from "./fr";
import { EN } from "./en";
import { FR } from "./fr";
export type Language = "fr" | "en";
export const TRANSLATIONS: Map<Language, Map<string, string>> = new Map([
["fr", new Map(FR.map((obj) => [obj.id, obj.translation]))],
["en", new Map(EN.map((obj) => [obj.id, obj.translation]))],
]);

Create the pages

/src/pages/[lang]/index.astro

---
import type { Languages } from "src/i18n";
export function getStaticPaths() {
return [{ params: { lang: "fr" } }, { params: { lang: "en" } }];
}
const lang = Astro.params.lang as Languages;
---
<h1>Homepage {lang}</h1>
<ComponentUsingI18n />

Problem

AstroJS renders ComponentUsingI18n twice :

  • One time on server (to generate HTML files)
  • One time on client (to hydrate it and make it dynamic)

So, we will need to pass 2 times the lang variable.

⚠️ The JS Components have to render the same DOM elements, else the hydratation won’t work. And we don’t have the Astro.params.lang variable in JSX/TSX components.

If HTML server side and client side is different, we would have errors like:

Warning: An error occurred during hydration. The server HTML was replaced with client content in <astro-island>.

How my solution works

A schema is better than 1000 words:

Loading graph...

Implementation with code

Render the components server side

I created a SessionStorage for the server Astro:

/src/functions/sessionStorageServer.ts
const _sessionStorage: any = {};
export const sessionStorageServer = {
setItem: (key: string, value: string) => (_sessionStorage[key] = value),
getItem: (key: string): string | undefined => _sessionStorage[key],
removeItem: (key: string) => delete _sessionStorage[key],
};

Then, I called on every astro pages:

/src/layouts/Layout.astro
// sessionStorageServer.setItem("lang", "en");
// sessionStorageServer.setItem("lang", "fr");
sessionStorageServer.setItem("lang", Astro.params.lang);
const lang = sessionStorageServer.getItem("lang");

Render the components client side

When the frontend is rendered by the client, we do:

const lang = document.documentElement.lang;

And we won’t forget to add in Astro layout :

---
const lang = sessionStorageServer.getItem(SESSION_STORAGE_KEY);
---
<!DOCTYPE html>
<html lang={lang}>...</html>

Refactoring : useTranslation() hook

Implementation

To create a common hook, first, we need to find the runtime. I use this function:

export type Runtime = "client" | "server";
export function detectRuntime(): Runtime {
if (typeof window === "undefined") {
return "server";
} else {
return "client";
}
}

My hook looks like this:

import { detectRuntime, Runtime, sessionStorageServer } from "src/functions";
import { Languages, TRANSLATIONS } from "src/i18n";
// export type Languages = "fr" | "en" | "de";
const SESSION_STORAGE_KEY = "lang";
/**
*
* To initialize the hook, please put a
*
* Server side:
* const { lang, t } = useTranslation(Astro.params.lang as Languages);
* <!DOCTYPE html><html lang={lang}>...</html>
*
* Or:
*
* Client side:
* const { ... } = useTranslation();
*/
export const useTranslation = (setupLang: Languages | null = null) => {
let lang: Languages | null = setupLang;
let runtime: Runtime = detectRuntime();
switch (runtime) {
case "server":
if (setupLang != null) {
sessionStorageServer.setItem(SESSION_STORAGE_KEY, setupLang);
}
lang = sessionStorageServer.getItem(SESSION_STORAGE_KEY) as Languages;
break;
case "client":
if (setupLang != null) {
throw new Error(
"The client can't init the language. Please pass the language as <html> `lang` attribute"
);
}
lang = document.documentElement.lang as Languages;
break;
default:
throw new Error(`Unknown runtime "${runtime}" found`);
}
return {
lang,
t: (key: string) => {
const translation = TRANSLATIONS.get(lang as Languages)?.get(key);
if (translation === undefined) {
throw new Error(
`Translation with lang=${lang} and key=${key} does not exist`
);
}
return translation;
},
};
};

Usage

To ensure i18n is properly set up on every page, you should include this code at the beginning of each file.

---
import { ReactComponent } from "src/components/Atoms";
const { lang, t } = useTranslation(Astro.params.lang as Languages);
---
<!DOCTYPE html>
<html lang={lang} class="...">
<head></head>
<body>
<span>{t("hello")}</span>
<ReactComponent />
</body>
</html>

And to write a component using i18n, we will do something like:

import { useState } from "react";
import { useTranslation } from "src/hooks/useTranslation";
export function ReactComponent() {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
const { lang, t } = useTranslation();
return (
<div className="bg-orange-200 text-center">
<div>Language : {lang}</div>
<div>{t("world")} ...</div>
<button
onClick={increment}
className="border-2 border-black bg-blue-400 p-4"
>
{count}
</button>
</div>
);
}

On my side, I have no problems to render and hydrate this component !

Thank you for reading my blog post !


Recommended articles