Adding basic internationalization (i18n) to your Svelte/SvelteKit app

Published

👋 Hola, Mundo!

Read this article en EspaĂąol (via Chema of Ideas Worth Tranlsating, thanks!)

I just recently stumbled upon a great video by Dr. Matthias Stahl (tweet here, YouTube video here, code here) on Svelte Society’s YouTube channel who came up with a simple approach to adding basic i18n translations to a Svelte app.

I thought it would be fun and informative to recreate it while also making some minor optimizations and enhancements along the way. 🤗

We’re going to be creating something like this:

translations in action

Most of the credit in this post goes to Matthias here, so make sure to check him out and give him a follow! 🙇

📒 Note: this isn’t a full featured internationalization solution like i18next, so this might not be the exact right solution for you!

Impatient? Checkout the Svelte REPL with all the code ↗️

Step 1: The translations object

In Matthias’s example, he uses a deeply nested object to store translations strings. This works, but it is slightly inefficient since you’ll have to traverse the object, especially if you have multiple layers of nested keys (think app => page => section => component => label).

I’ve instead opted for a flat object with the key being the internationalization locale subtag (eg en and not en-US) and a string representing a dot-separated namespace for a translation value. When we’re dealing with many translations, this should have a slight performance benefit.

In addition, we will support embedded variables and HTML in our translation strings:

// translations.js
export default {
	en: {
		"homepage.title": "Hello, World!",
		"homepage.welcome": "Hi <strong>{{name}}</strong>, how are you?",
		"homepage.time": "The current time is: {{time}}",
	},
	es: {
		"homepage.title": "ÂĄHola Mundo!",
		"homepage.welcome": "Hola, <strong>{{name}}</strong>, ÂżcĂłmo estĂĄs?",
		"homepage.time": "La hora actual es: {{time}}",
	},
};

This will allow us to have namespaced keys as well as supporting rich formatting and injecting values (e.g. strings, numbers, dates, etc).

Step 2: The component

We will now create our Svelte component, huzzah! 👏

This component is pretty simple and will consist of a select dropdown to choose the language the user wants to use as well as displaying some translation text including one with HTML and custom variables!

App.svelte:

<script>
  import { t, locale, locales } from "./i18n";

  // Create a locale specific timestamp
  $: time = new Date().toLocaleDateString($locale, {
    weekday: "long",
    year: "numeric",
    month: "long",
    day: "numeric",
  });
</script>

<main>
  <p>
    <select bind:value="{$locale}">
      {#each locales as l}
        <option value="{l}">{l}</option>
      {/each}
    </select>
  </p>

  <h1>{$t("homepage.title")}!</h1>
  <p>{@html $t("homepage.welcome", { name: "Jane Doe" })}!</p>
  <p>{$t("homepage.time", { time })}!</p>
</main>

What we’re doing here is connecting a <select> element to a Svelte store (which we will create in a second) and also using a magic $t() method which will allow us to do translation lookups.

You’ll also notice we’re creating a locale specific timestamp to show the user using toLocaleDateString which we pass the $locale store value to.

If this doesn’t make sense yet, that’s ok, keep reading!

Step 3: The store

Now for the fun part, let’s create our Svelte store! 👯‍♂️

The store itself is quite simple, basically we just store the locale value (e.g. en, es, etc) in one store and then create a derived store from the locale and the translations object we created earlier.

import { derived, writable } from "svelte/store";
import translations from "./translations";

export const locale = writable("en");
export const locales = Object.keys(translations);

function translate(locale, key, vars) {
	// Let's throw some errors if we're trying to use keys/locales that don't exist.
	// We could improve this by using Typescript and/or fallback values.
	if (!key) throw new Error("no key provided to $t()");
	if (!locale) throw new Error(`no translation for key "${key}"`);

	// Grab the translation from the translations object.
	let text = translations[locale][key];

	if (!text) throw new Error(`no translation found for ${locale}.${key}`);

	// Replace any passed in variables in the translation string.
	Object.keys(vars).map((k) => {
		const regex = new RegExp(`{{${k}}}`, "g");
		text = text.replace(regex, vars[k]);
	});

	return text;
}

export const t = derived(
	locale,
	($locale) =>
		(key, vars = {}) =>
			translate($locale, key, vars)
);

The majority of the logic is in the translate method which looks up the keys and injects the variables, if present.

The derived store will stay in sync with the current locale and thus our translate method will always received the current locale when being called. When the locale is updated, the $t() calls will be re-computed and thus update all our text in our Svelte component when the user changes their locale. Cool! 😎

This departs a bit from Matthias’s version as it doesn’t require creating an extra store for the translation which isn’t strictly necessary and is a bit more efficient if we omit it.

Step 4: Putting it together

Now that we have our store, we have all the pieces to create a basic internationalization system in Svelte, congrats 🎉

If you want to see this code in action, have a look at the Svelte REPL

Step 5: Going further

Now, this option isn’t right for everyone. If you’re building a large, robust, content-heavy application with many translations, then maybe you’ll want to consider something like Locize in combination with i18next. You can always integrate their JS libraries with Svelte in a similar way.

We are also not sanitizing any of the HTML content, so if you’re injecting user supplied data in your translation strings, you’ll need to make sure to sanitize/strip the input so as not to create an XSS vulnerability! 🔐

Another issue with this approach is there is no real fallback behavior for a missing translation (right now we’re just throwing errors which is probably not what you want).

That said, a solution like this can be helpful when you don’t need a full-blown translation platform and just need relatively basic string translations.

You could extend this example by persisting the locale value in local storage and defaulting to the browser’s preferred language by, for example, using the navigator.languages property. This is a subject in its own right!

Step 6: Wrapping up

Checkout the Svelte REPL for all the code in a live editing environment you can mess around with! 🤓

I think this example shows us a few interesting properties of Svelte, including:

1️⃣ How to implement a functional but basic i18n implementation in very little code 2️⃣ How to use a derived store which returns a function 3️⃣ How to use global stores and how to set those values in our components 4️⃣ How to use toLocaleDateString to get locale-specific date formatting

Hopefully this was entertaining for you and don’t forget to give Matthias a shoutout for his original post!


Like this post?
Why don't you let me know on Twitter: @danawoodman