Typesafe i18n with SvelteKit

Typesafe i18n with SvelteKit

How easy it is to create a multi-language site nowadays!

Featured on Hashnode

Creating multi-language support for a website is a good example of how "rolling your own" is easy at first, but gradually becomes more complicated until the realization that using a library would've been wiser. But then the challenge is to select an internationalization library that will play nicely with your framework of choice.

This article will show you how to connect two modern, well-thought-out parts of the web app toolchain. Let's start with the framework.

Most people seem to agree that SvelteKit has succeeded in creating a both powerful and joyful web development experience. And with the recent 1.0 release, there's no question that it's our framework of choice here.

Edit: The code for this article is available on github here.

Creating the SvelteKit project from scratch

The only prerequisite is Node.js, which comes with its package manager npm. It's used here, but for a faster and overall nicer experience, install pnpm and substitute all npm and npx commands with pnpm below. Open a terminal in your project folder and initialize a new SvelteKit project called "i18n" with:

npm create svelte@latest i18n

At the first two prompts, select Skeleton project and Typescript syntax, the rest is up to you. Then we're installing its dependencies:

cd i18n
npm install

Installing the i18n library

Our i18n library of choice is called typesafe-i18n, selected because it gets so many things right, from syntax to output size. We will also install a package called npm-run-all to be able to update the translation files in parallel with running the SvelteKit dev server:

npm install -D typesafe-i18n npm-run-all

After that, we will generate the configuration file for the translation:

npx typesafe-i18n --setup-auto

This will generate a .typesafe-i18n.json in the root project folder, that we should edit to specify an outputPath field. This is because we want to use SvelteKit's convenient $lib alias so we can refer to the translation anywhere in the project. Open .typesafe-i18n.json and add it:

.typesafe-i18n.json

{
  ...
  "outputPath": "./src/lib/i18n/"
}

Finally, we will modify package.json to run typesafe-i18n and the vite dev server that SvelteKit uses at the same time:

.package.json

{
  ...
  "scripts": {
    "dev": "npm-run-all --parallel vite typesafe-i18n",
    "vite": "vite dev --open"
    ...
  }
}

As you may have noticed further down in the file, typesafe-i18n has been added to package.json already.

Before starting SvelteKit, let's quickly go through the different levels of translation that typesafe-i18n provides.

LLL, LL and L

There are three different levels to consider, the lowest being represented by the LLL object. It deals with a single translation string, transforming it to proper output. Knowing the translation string syntax is essential, but we won't use this object directly, since the next level is a more convenient way of outputting translated text in our SvelteKit app.

The LL object is much more suitable for the client, since it contains all strings for a specific locale (language settings with formatting rules) - we don't need to transfer all languages to the client, just the one we need.

Finally, the L object contains all available locales, making it more useful server-side.

We're going to use the LL object, which will be available to us in the auto-generated language files. So let's start up the dev server and see what happens:

npm run dev

Files have now been generated in src/lib/i18n, together with two example languages, en (the default) and de. Each language is represented by a string object that you can organize however you'd like, as a flat structure, or nested.

Also generated is a src/lib/i18n/i18n-svelte.ts file that exports a LL object. This is the one we're going to use, but first, given that we have two languages, how do we choose which one to load?

Using routing for language detection

Some apps will have the current locale stored in user settings or a cookie, but we will take the route approach, having the locale in the first portion of the URL, like /de/some/page. (Changing to another way of detecting the locale is quite simple, as you will see later.)

SvelteKit's flexible routing system makes this quite easy. We're gonna make use of a route like this:

[lang]

Square brackets signify a route parameter, so we can use parts of the route dynamically. In this case, we want to access the first part of the route as the lang parameter. But we don't want it to be required, since the default language en shouldn't have its locale code prefixed. With double brackets it will be optional, meaning that routes like /de/some/page and /some/page will result in the same page being loaded.

Let's display a translation string, but first some cleanup: Delete the route files in src/routes so they won't conflict with the new route. Then create the route directory src/routes/[[lang]] (with double brackets) and a +page.svelte file inside it:

src/routes/[[lang]]/+page.svelte

<script lang="ts">
  import { LL } from '$lib/i18n/i18n-svelte';
</script>

<h1>{$LL.HI({name: "World"})}</h1>

If you open the project in a browser, you'll see a message blink by quickly. This is because the default locale loads automatically on the server, but not on the client.

We don't have a layout yet, but a +layout.ts file can be quite useful here, since (from the SvelteKit docs) "+page.js and +layout.js files export universal load functions that run both on the server and in the browser".

With a top-level layout file, we make sure the current locale is always loaded for every page below it, both on the server and client. This is the place where we want to detect the locale based on the route. Let's start simple:

src/routes/+layout.ts

import type { LayoutLoad } from './$types';

export const load = (async (event) => {
  return event.data;
}) satisfies LayoutLoad;

This minimal file just passes on the event data from a future +layout.server.ts file. And the event parameter contains a params property with the lang route that we specified earlier.

Locale detection

Instead of using event.params.lang directly to set the locale, a detectLocale function is available from the generated i18n files. It's preferred to use since we can supply it with a matcher function, which will return a strongly typed locale (the default if the matcher fails), so we don't have to check for incorrect values. Let's use it:

src/routes/+layout.ts

import type { LayoutLoad } from './$types';
import { detectLocale } from '$lib/i18n/i18n-util';

export const load = (async (event) => {
  const locale = detectLocale(() => [event.params.lang ?? '']);
  // TODO: Load and set locale
  return event.data;
}) satisfies LayoutLoad;

Now we have the locale, but we must load it and set it before it can be displayed on the client. Fortunately, the generated files have us covered. First, we'll use the loadLocaleAsync utility function, then we use a Svelte-specific setLocale to populate the LL object we used earlier. Here's the completed layout file:

src/routes/+layout.ts

import type { LayoutLoad } from './$types';
import { loadLocaleAsync } from '$lib/i18n/i18n-util.async';
import { setLocale } from '$lib/i18n/i18n-svelte';
import { detectLocale } from '$lib/i18n/i18n-util';

export const load = (async (event) => {
  // Detect the locale
  const locale = detectLocale(() => [event.params.lang ?? '']);
  // Load it
  await loadLocaleAsync(locale);
  // Set it
  setLocale(locale);

  return event.data;
}) satisfies LayoutLoad;

Now open the front page, and you should see the translation string. Change the url to /de and the german version will load.

Adding translation strings and another page

Adding more translation strings and routes is as easy as adding to the src/lib/i18n/en/index.ts file and its de counterpart, following the syntax of typesafe-i18n. It makes things like plural rather simple, which could be a pain to implement by yourself:

src/lib/i18n/en/index.ts

const en: BaseTranslation = {
  ...
  APPLES: '{apples:number} apple{{s}}',
};

We'll use this soon, but first, we need to translate it into german. When opening the corresponding src/lib/i18n/de/index.ts file, you'll be notified that the APPLES property is missing, which is a nice reminder to get. But we have another problem, the singular and plural of apple are spelled differently in german. Well, no problem:

src/lib/i18n/de/index.ts

const de: Translation = {
  ...
  APPLES: '{apples} {{Apfel|Äpfel}}',
};

Note that unlike the en file, we don't have to specify the type of the {apples} argument. Now let's create the new route by adding two folders and files:

src/routes[[lang]]/apples/[amount]/+page.ts

import type { PageLoad } from './$types';

export const load = (async (event) => {
  return { 
    apples: parseInt(event.params.amount) 
  };
}) satisfies PageLoad;

src/routes[[lang]]/apples/[amount]/+page.svelte

<script lang="ts">
  import { LL } from '$lib/i18n/i18n-svelte';
  import type { PageData } from './$types';

  export let data: PageData;
</script>

<h1>{$LL.APPLES(data)}</h1>

Try it out by browsing to /de/apples/2 for example. Convenient, isn't it?

Linking between pages

We have pretty much covered the basics now, but one thing that's easy to overlook is how to link between pages while keeping the locale intact. The solution is to translate the links as well, prepending the locale before the actual route:

src/lib/i18n/en/index.ts

const en: BaseTranslation = {
  ...
  LINK: '{0}', // No prefix needed
};

src/lib/i18n/de/index.ts

const de: Translation = {
  ...
  LINK: '/de{0}', // Prepending the locale
};

Now we can link back to the start page:

src/routes/[[lang]]/apples/[amount]/+page.svelte

<h1>{$LL.APPLES(data)}</h1>

<a href={$LL.LINK('/')}>Back to start</a>

And it should be no problem for you to translate the "Back to start" phrase now if you'd like!

Matching only the defined languages

We have a slight problem though: We can enter anything as the "lang" route parameter, and the start page will be loaded in english. We'd like any non-existing language to return a 404 instead.

This can be done using a route matcher for matching only the languages we have a translation for. We're using the locale data from i18n-util to exclude the default locale and check if the parameter matches one of the others:

src/params/langCode.ts

import type { ParamMatcher } from '@sveltejs/kit';
import { baseLocale, locales } from '$lib/i18n/i18n-util';
import type { Locales } from '$lib/i18n/i18n-types';

export const match: ParamMatcher = (param) => {
  return param !== baseLocale && locales.includes(param as Locales);
};

The name of this file (without extension), langCode, is what we should use in the route name. So rename the [[lang]] route directory to [[lang=langCode]] and try to browse a non-existing locale. A 404 should be the result. The same could be done with the [amount] route parameter, using a matcher that checks for an integer.

To keep the routes clean, you may want to store the selected locale in a cookie instead. This isn't too hard to do, but it requires a change; the src/routes/+layout.ts file doesn't have direct access to cookies, so we need to use an additional +layout.server.ts file, with some minor changes to +layout.ts:

src/routes/+layout.ts

import type { LayoutLoad } from './$types';
import { loadLocaleAsync } from '$lib/i18n/i18n-util.async';
import { setLocale } from '$lib/i18n/i18n-svelte';

export const load = (async (event) => {
  // Locale now comes from the server instead of the route
  const locale = event.data.locale;
  // But we load and set it as before
  await loadLocaleAsync(locale);
  setLocale(locale);

  return event.data;
}) satisfies LayoutLoad;

src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types';
import { detectLocale } from '$lib/i18n/i18n-util';
import { redirect } from '@sveltejs/kit';

const langParam = 'lang';

export const load = (async (event) => {
  // Using a GET var "lang" to change locale
  const newLocale = event.url.searchParams.get(langParam);
  if (newLocale) {
    event.cookies.set(langParam, newLocale, { path: '/' });
    event.url.searchParams.delete(langParam);
    // Redirect to remove the GET var    
    throw redirect(303, event.url.toString());
  }

  // Get the locale from the cookie
  const locale = detectLocale(() => [event.cookies.get(langParam) ?? '']);
  return { locale };
}) satisfies LayoutServerLoad;

Even cookie handling is trivial with SvelteKit! We're now passing on the locale from the server to the universal +layout.ts file, which will use event.data.locale instead of event.params.lang as before. And everything is strongly typed, again thanks to SvelteKit.

Test this out by browsing to:

/apples/1?lang=de

This also means that we don't need the $LINK route translation anymore, so it can be removed.

Conclusion

I hope this article has helped show that i18n is quite manageable once you pass the "setup hurdle". It's great to see that both typesafe-i18n and SvelteKit are leveraging Typescript to give useful hints about route parameters, missing translation strings, etc.

To dive deeper into typesafe-i18n, check out its Table of Contents, the SvelteKit documentation is available here, and let me know if you have any questions or comments. Thanks for reading!