# Typesafe i18n with SvelteKit

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](https://kit.svelte.dev/) 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](https://github.com/ciscoheat/typesafe-i18n-with-sveltekit).

# Creating the SvelteKit project from scratch

The only prerequisite is [Node.js](https://nodejs.org/en/), which comes with its package manager `npm`. It's used here, but for a faster and overall nicer experience, install [pnpm](https://pnpm.io/) 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:

```bash
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:

```bash
cd i18n
npm install
```

## Installing the i18n library

Our i18n library of choice is called [typesafe-i18n](https://github.com/ivanhofer/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:

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

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

```bash
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**

```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**

```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](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/runtime#i18nstring) object. It deals with a single translation string, transforming it to proper output. Knowing the translation string [syntax](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/runtime#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](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/runtime#i18nobject) 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](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/runtime#i18n) 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:

```bash
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:

```plaintext
[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**

```typescript
<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](https://kit.svelte.dev/docs/load#universal-vs-server)) *"+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**

```typescript
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](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/detectors#typesafe-i18n-detectors), 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**

```typescript
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**

```typescript
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](https://github.com/ivanhofer/typesafe-i18n/tree/main/packages/runtime#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**

```typescript
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**

```typescript
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**

```typescript
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**

```typescript
<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**

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

**src/lib/i18n/de/index.ts**

```typescript
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**

```xml
<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](https://kit.svelte.dev/docs/advanced-routing#matching) 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**

```typescript
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.

## One final challenge: Using a cookie instead of routes

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**

```typescript
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**

```typescript
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:

```typescript
/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](https://github.com/ivanhofer/typesafe-i18n#table-of-contents), the SvelteKit documentation is [available here](https://kit.svelte.dev/docs), and let me know if you have any questions or comments. Thanks for reading!
