I18N for solid-start apps

Vladislav Lipatov
7 min readApr 23, 2024

--

In this article, I will discuss how you can support multiple languages in a solid-start application. The implementation can be completely different, so I will highlight a few points that the implementation should meet:

  1. The server should detect the preferred language and give it if it is supported in the application. Otherwise the default language should be given.
  2. There should be no unnecessary translations in the JS bundle that is loaded by the client.
  3. Translations should change without reloading the page.

You will be able to customize the solutions below quite easily to suit your needs.

Implementation

In the examples I will use the solid-start@1.0.0-rc version. It is possible that the API will change in future versions.

I will implement the service from scratch to better understand how everything works. First of all, let’s create a bare project:

bun create solid

Step 1: i18n service

Let’s create a small service that will store information about the current application language. The language will be available to child components via context:

i18n.tsx:

import { type Accessor, type ParentComponent, createContext, createSignal } from "solid-js";

// Supported languages
type Locale = "en" | "ru";

const DEFAULT_LOCALE: Locale = "en";

export const I18NServiceContext = createContext<{ locale: Accessor<Locale> }>();

export const I18NService: ParentComponent = (props) => {
const [locale, setLocale] = createSignal<Locale>();

return <I18NServiceContext.Provider value={{ locale }}>{props.children}</I18NServiceContext.Provider>;
};

Let’s say my application will support two locales: en and ru. So far, I don’t know which initial value to assign to the signal: en or ru? To decide let’s remember the first requirement:

Server must detect the preferred language and give it if it is supported in the application. Otherwise, the default language must be given.

How can we determine the user’s language? We can get this information from the accept-language header that comes in the request. Let’s create a simple function that will return the locale from the request:

const getInitialLocale = (): Locale => {
const acceptLanguage = // get language from header somehow

// parse locale somehow
const locale = // parse header here

return (locale as Locale) ?? DEFAULT_LOCALE;
};

solid-js provides a getRequestEvent function that returns request data and only works on the server, so we can use this function to set the initial value of the signal:

const getInitialLocale = (): Locale => {
const acceptLanguage = getRequestEvent()?.request.headers.get("accept-language") ?? "";

// parse locale somehow
const locale = acceptLanguage.split(";")[0].split(",")[0];

return (locale as Locale) ?? DEFAULT_LOCALE;
};

// ...

const [locale, setLocale] = createSignal<Locale>(getInitialLocale());

However, there is a problem in this code. The point is that getRequestEvent works only on the server, so on the client the signal value will be incorrect and the functionality will not work properly. To fix this, let’s add the ”use server” directive to the getInitialLocale function, then the function will return Promise with the locale. Since we need to get the locale from Promise, let’s replace the signal with createResource:

const getInitialLocale = async (): Promise<Locale> => {
"use server";

const acceptLanguage = getRequestEvent()?.request.headers.get("accept-language") ?? "";

// parse locale somehow
const locale = acceptLanguage.split(";")[0].split(",")[0];

return (locale as Locale) ?? DEFAULT_LOCALE;
};

// ...

export const I18NService: ParentComponent = (props) => {
const [localeResource] = createResource<Locale>(getInitialLocale);

// in the console you'll see preferred locale
console.log(localeResource());

return (
<I18NServiceContext.Provider value={{ locale: () => localeResource()! }}>
{props.children}
</I18NServiceContext.Provider>
);
};

Now the locale detection works. In my case, the accept-language header contains ru,en;q=0.9,en-GB;q=0.8,en-US;q=0.7, so I will see ru in the console.

Step 2: components with translations

Now let’s get to our components. I will create two components that will have translations for different locales. These components will render plain text depending on the current locale (i.e. they will use I18NServiceContext). I will also create translation objects next to these components. To do this, let’s add some types to our i18n service:

// This is a simple type
// You may extend it with functions, arrays and recursive calls
type LanguageObject = Record<string, string>;

export type LazyLanguageMap<T extends LanguageObject = LanguageObject> = Record<Locale, () => Promise<{ default: T }>>;

Components will have the same file structure:

- index.tsx - components' code
- i18n
- en.ts - contains translations for `en` locale
- ru.ts - contains translations for `ru` locale

ComponentA ‘s code:

import { useContext } from "solid-js";
import { I18NServiceContext, type LazyLanguageMap, createLocales } from "~/i18n";

const languageMap: LazyLanguageMap = {
en: () => import("./i18n/en"),
ru: () => import("./i18n/ru"),
};

export const ComponentA = () => {
const { locale } = useContext(I18NServiceContext)!;

const t = // somehow get current language object

// use it
return <div>Component A: {t()?.hi}</div>;
};

en.ts:

export default {
hi: "Hi",
};

ru.ts:

export default {
hi: "Привет",
};

Now we need a function that will correctly import the desired translations and provide a signal with them. Such a function can look like this:

export const createTranslations = <Translations extends LanguageObject>(
locales: LazyLanguageMap<Translations>,
locale: Accessor<Locale>
): Resource<Translations> =>
createResource(locale, async (currentLocale) => (await locales[currentLocale]()).default)[0];

In this case, I’m using a resource to deal with asynchrony correctly. Now the code of ComponentA can look like this:

import { useContext } from "solid-js";
import { I18NServiceContext, LazyLanguageMap, createTranslations } from "~/i18n";

const languageMap: LazyLanguageMap = {
en: () => import("./i18n/en"),
ru: () => import("./i18n/ru"),
};

export const ComponentA = () => {
const { locale } = useContext(I18NServiceContext)!;

const t = createTranslations(languageMap, locale);

return <div>Component A: {t()?.hi}</div>;
};

I will create another exactly the same component, but with a different translations object. Now let’s display our components in the application (app.tsx):

// app.tsx
import { Suspense } from "solid-js";

import { I18NService } from "./i18n";

import { ComponentA } from "./components/ComponentA";
import { ComponentB } from "./components/ComponentB";

export default function App() {
return (
// Don't forget to wrap your app into Suspense
<Suspense>
<I18NService>
<ComponentA />
<ComponentB />
</I18NService>
</Suspense>
);
}

Step 3: change language

Let’s add a method to our service to change the language. To do this, we just need to mutate our resource with mutate. However, this will result in a Suspense trigger, which we don’t want to happen. To change the language smoothly, let’s wrap mutate in startTransition:

const changeLocale = (locale: Locale) => startTransition(() => mutate(locale));

Now let’s add this method to the context and create a language switch button in app.tsx:

export default function App() {
return (
<Suspense>
<I18NService>
{untrack(() => {
const { locale, changeLocale } = useContext(I18NServiceContext)!;

return (
<button type="button" onClick={() => changeLocale(locale() === "en" ? "ru" : "en")}>
Change language
</button>
);
})}

<ComponentA />
<ComponentB />
</I18NService>
</Suspense>
);
}

Now language switching works, translations load lazily and the preferred language is given on initial app load.

The current approach has a number of non-obvious points, which I will discuss below.

Final code

// i18n.tsx

import {
type Accessor,
type ParentComponent,
createContext,
createResource,
type Resource,
startTransition,
} from "solid-js";
import { getRequestEvent } from "solid-js/web";

// Supported languages
type Locale = "en" | "ru";
type LanguageObject = Record<string, string>;
export type LazyLanguageMap<T extends LanguageObject = LanguageObject> = Record<Locale, () => Promise<{ default: T }>>;

const DEFAULT_LOCALE: Locale = "en";

export const createTranslations = <Translations extends LanguageObject>(
locales: LazyLanguageMap<Translations>,
locale: Accessor<Locale>
): Resource<Translations> =>
createResource(locale, async (currentLocale) => (await locales[currentLocale]()).default)[0];

const getInitialLocale = async (): Promise<Locale> => {
"use server";

const acceptLanguage = getRequestEvent()?.request.headers.get("accept-language") ?? "";

// parse locale somehow
const locale = acceptLanguage.split(";")[0].split(",")[0];

return (locale as Locale) ?? DEFAULT_LOCALE;
};

export const I18NServiceContext = createContext<{ locale: Accessor<Locale>; changeLocale: (locale: Locale) => void }>();

export const I18NService: ParentComponent = (props) => {
const [localeResource, { mutate }] = createResource<Locale>(getInitialLocale);

const changeLocale = (locale: Locale) => startTransition(() => mutate(locale));

return (
<I18NServiceContext.Provider value={{ locale: () => localeResource()!, changeLocale }}>
{props.children}
</I18NServiceContext.Provider>
);
};
// app.tsx

import { Suspense, untrack, useContext } from "solid-js";

import { I18NService, I18NServiceContext } from "./i18n";

import { ComponentA } from "./components/ComponentA";
import { ComponentB } from "./components/ComponentB";

export default function App() {
return (
<Suspense>
<I18NService>
{untrack(() => {
const { locale, changeLocale } = useContext(I18NServiceContext)!;

return (
<button type="button" onClick={() => changeLocale(locale() === "en" ? "ru" : "en")}>
Change language
</button>
);
})}
<ComponentA />
<ComponentB />
</I18NService>
</Suspense>
);
}
// Component{X}/index.tsx

import { useContext } from "solid-js";
import { I18NServiceContext, type LazyLanguageMap, createTranslations } from "~/i18n";

const languageMap: LazyLanguageMap = {
en: () => import("./i18n/en"),
ru: () => import("./i18n/ru"),
};

export const ComponentA = () => {
const { locale } = useContext(I18NServiceContext)!;

const t = createTranslations(languageMap, locale);

return <div>Component A: {t()?.hi}</div>;
};

Improvements

  1. You may not store translations in one object without lazy loading, then the code will be simpler, but also the size of the bandlet will be bigger.
  2. You can store locale in URL, then don’t forget about redirects.
  3. You can implement translation change through simple page reload, then the code will be even less.

Caveats

Since translation files are imported lazily, when the language is changed, there will be as many requests to the server for translation files as there are components currently in use. In my example there are 2 components, so there will be 2 requests for translations, however in a real application this number could be much higher. This will definitely have a bad impact on performance as the browser cannot handle more than n (=5 ?) requests in parallel, so correctly define the level at which you will store translations (component level, feature level, page level or application level?). In my example, I store translations next to the component, which is quite convenient, but not always a good thing. You may have to sacrifice convenience for performance, or you may have to come up with a more interesting way of storing translations so that they can be combined into a single structure.

I hope this article was helpful to you!

--

--