Skip to main content
Solid Primitives 2

Library of primitives for providing internationalization support.

StageCategoryVersionLast UpdatedDemo
3Utilities3.0.0-next.7 (next)Nov 12, 2026Demo →
Terminal window
npm i @solid-primitives/i18n@next

Library of primitives for providing internationalization support.

How to use it

The library consists of multiple small and composable primitives that can be used together to create an internationalization solution that fits your needs.

Defining dictionaries

Dictionary is any plain js object that contains translations for a given language. It can be nested and contain functions.

Dictionaries can be defined in inline in js, or imported from json files.

const en_dict = {
hello: "hello {{ name }}, how are you?",
goodbye: (name: string) => `goodbye ${name}`,
food: {
meat: "meat",
fruit: "fruit",
},
};
type Dict = typeof en_dict;
const fr_dict: Dict = {
hello: "bonjour {{ name }}, comment vas-tu ?",
goodbye: (name: string) => `au revoir ${name}`,
food: {
meat: "viande",
fruit: "fruit",
},
};

When using large dictionary files, JSON files are faster to load. Additionally, we recommend keeping a flat JSON structure so you don't need to flatten the object on the client for best performance.

flatten

Flattens a nested dictionary so every nested value is also available under a dot-separated path.

Loading playground…

Dynamic loading

Use createMemo with an async function to load dictionaries on demand. The translator suspends inside a <Loading> boundary until the dictionary resolves.

import { type Component, createMemo, createSignal, Loading } from "solid-js";
import * as i18n from "@solid-primitives/i18n";
/*
Assuming the dictionaries are in the following structure:
./i18n
en.ts
fr.ts
es.ts
And all export a `dict` object
*/
// use `type` to not include the actual dictionary in the bundle
import type * as en from "./i18n/en.js";
export type Locale = "en" | "fr" | "es";
export type RawDictionary = typeof en.dict;
export type Dictionary = i18n.Flatten<RawDictionary>;
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
const dict: RawDictionary = (await import(`./i18n/${locale}.ts`)).dict;
return i18n.flatten(dict); // flatten the dictionary to make all nested keys available top-level
}
const App: Component = () => {
const [locale, setLocale] = createSignal<Locale>("en");
const dict = createMemo(async () => fetchDictionary(locale()));
const t = i18n.translator(dict, i18n.resolveTemplate);
return (
<Loading fallback={<div>Loading...</div>}>
<div>
<p>Current locale: {locale()}</p>
<div>
<button onClick={() => setLocale("en")}>English</button>
<button onClick={() => setLocale("fr")}>French</button>
<button onClick={() => setLocale("es")}>Spanish</button>
</div>
<h4>{t("hello", { name: "John" })}</h4>
<h4>{t("goodbye", { name: "John" })}</h4>
<h4>{t("food.meat")}</h4>
</div>
</Loading>
);
};

With transitions

Use isPending(dict) to show visual feedback while a locale switch is in progress.

import { createMemo, isPending, Loading } from "solid-js";
const dict = createMemo(async () => fetchDictionary(locale()));
return (
<div style={{ opacity: isPending(dict) ? 0.5 : 1 }}>
<Loading>
<App />
</Loading>
</div>
);

Static dictionaries

If you don't need to load dictionaries dynamically, use createMemo with a synchronous function.

import * as en from "./i18n/en.js";
import * as fr from "./i18n/fr.js";
import * as es from "./i18n/es.js";
const dictionaries = {
en: en.dict,
fr: fr.dict,
es: es.dict,
};
const [locale, setLocale] = createSignal<Locale>("en");
const dict = createMemo(() => i18n.flatten(dictionaries[locale()]));
const t = i18n.translator(dict);

translator

Creates a t() function that resolves paths in a reactive dictionary, re-running when the dictionary changes.

Loading playground…

Templates

Templates are strings that can contain placeholders. Placeholders are defined with double curly braces {{ placeholder }}.

resolveTemplate

Replaces {{ placeholder }} occurrences in a string with the provided values.

Loading playground…

Templates can be resolved by calling resolveTemplate function. e.g.

i18n.resolveTemplate("hello {{ name }}!", { name: "John" }); // => 'hello John!'

By default, the translator function will not resolve templates. You can pass resolveTemplate as the second argument to translator to enable template resolution. Or use a custom template resolver.

const dict = {
hello: "hello {{ name }}!",
};
const t1 = i18n.translator(() => dict);
t1("hello", { name: "John" }); // => 'hello {{ name }}!'
const t2 = i18n.translator(() => dict, i18n.resolveTemplate);
t2("hello", { name: "John" }); // => 'hello John!'

Rich text

Sometimes a translation needs to contain a link, or otherwise wrap part of the text in a component — not just interpolate a value.

For dictionaries you own in TS/TSX, a dictionary entry can simply be a function that returns JSX. Function entries have no restrictions on argument or return types, so this works with full type-checking and no extra API:

const dict = {
forMoreInfo: (clickHere: JSX.Element) => <>For more information, {clickHere}</>,
seeGuidelines: (guidelines: (text: string) => JSX.Element, name: string) => (
<>Please refer to {guidelines(name)}</>
),
};
const t = i18n.translator(() => dict);
t("forMoreInfo", <a href="/info">click here</a>);
t("seeGuidelines", text => <a href="/guidelines">{text}</a>, "the guidelines");

resolveRichTemplate

For dictionaries loaded from JSON (translated strings, no functions allowed), use resolveRichTemplate in place of resolveTemplate to allow JSX values in {{ placeholder }} substitutions:

Loading playground…
const dict = {
forMoreInfo: "For more information, {{ clickHere }}",
};
const t = i18n.translator(() => dict, i18n.resolveRichTemplate);
t("forMoreInfo", { clickHere: <a href="/info">click here</a> }); // => JSX.Element

richText

To wrap part of a translated string in a component, e.g. <guidelines>the guidelines</guidelines>, resolve {{ }} variables first with resolveTemplate, then pass the result through richText to map <tag> markup to a renderer:

Loading playground…
const dict = {
message: "Please refer to <guidelines>{{ name }}</guidelines>",
};
const t = i18n.translator(() => dict, i18n.resolveTemplate);
i18n.richText(t("message", { name: "the guidelines" }), {
guidelines: text => <a href="/guidelines">{text}</a>,
});

Tag names aren't type-checked against the tags map — a typo can't be caught at compile time. To catch it at runtime instead, an unmapped tag logs a dev-only warning and renders its contents as plain text; this check is stripped from production builds. Tags don't nest.

Missing keys

By default, a path that isn't present in the dictionary resolves to undefined — same as when the dictionary itself hasn't loaded yet. To make missing translations visible instead of silently rendering blank, pass an onMissingKey handler as the third argument to translator. It's only called when the dictionary itself is loaded but the requested path isn't in it.

const dict = { hello: "hello!" };
// fall back to the path itself, e.g. "goodbye"
const t = i18n.translator(() => dict, i18n.resolveTemplate, i18n.missingKeyAsPath);
t("hello"); // => 'hello!'
t("goodbye"); // => 'goodbye'
// or provide a custom handler, e.g. to report missing translations
const t2 = i18n.translator(
() => dict,
i18n.resolveTemplate,
path => {
reportMissingTranslation(path);
return "";
},
);

Source text as keys

Instead of inventing a key for every string, you can use the source-language text itself as the key. onMissingKey receives the arguments passed to the translator, and missingKeyAsTemplate uses that to resolve the key itself as a template — so the source language needs no dictionary entries at all, and other languages' dictionaries are keyed by the source text.

missingKeyAsTemplate

A missing-key handler that resolves the key itself as a template.

Loading playground…
// Type
function missingKeyAsTemplate<O = string>(
resolveTemplate: TemplateResolver<O>,
): MissingKeyHandler<O>;
// Example
const de = { "Hello {{ name }}!": "Hallo {{ name }}!" };
const dict = (): Record<string, string> => (locale() === "de" ? de : {});
const t = i18n.translator(
dict,
i18n.resolveTemplate,
i18n.missingKeyAsTemplate(i18n.resolveTemplate),
);
t("Hello {{ name }}!", { name: "John" }); // => 'Hello John!' (en), 'Hallo John!' (de)
t("Not translated yet"); // => 'Not translated yet' in every locale

Like any onMissingKey handler, it only runs once the dictionary is loaded — while an async dictionary is still loading, the translation is undefined. Keys are matched exactly, so changing the source text means updating the key in every other dictionary. There's no automatic key extraction or hashing; collecting the strings into dictionaries is left to your own tooling.

Modules

Splitting the dictionary into multiple modules can be useful when you have a large dictionary and want to avoid loading the entire dictionary at once.

For example, if our app had a separate login and dashboard modules, we could split the dictionary into 3 modules: (common, login and dashboard).

i18n/
en.json
pl.json
modules/
login/
i18n/
en.json
pl.json
login.ts
...
root.ts

Translations in root.ts would be available in all modules. Translations in login.ts would be available only in login module, and the same for other modules.

root.ts
const [locale, setLocale] = createSignal<Locale>("en");
const commonDict = createMemo(async () => fetchCommonDictionary(locale()));
const t = i18n.translator(commonDict);
// login/login.ts
const loginDict = createMemo(async () => fetchLoginDictionary(locale()));
// translator only for login module
const loginT = i18n.translator(loginDict);
t("welcome"); // => 'Welcome from common translations!'
loginT("welcome"); // => 'Welcome from login translations!'

Or combine multiple dictionaries into one, prefixing the keys with the module name.

const combined_dict = createMemo(() => ({
...i18n.prefix(commonDict(), "common"),
...i18n.prefix(loginDict(), "login"),
}));
const t = i18n.translator(combined_dict);
t("common.welcome"); // => 'Welcome from common translations!'
t("login.welcome"); // => 'Welcome from login translations!'

scopedTranslator

To scope an existing translator to a module, you can use scopedTranslator.

Loading playground…
const dict = {
"login.username": "User name",
"login.password": "Password",
"login.login": "Login",
// ...
};
const t = i18n.translator(() => dict);
const loginT = i18n.scopedTranslator(t, "login");
loginT("username"); // => 'User name'

Nested objects syntax

String paths passesd to the translator don't allow for taking advantage of TypeScript's "Go to definition", and "Find all references", "Rename" features.

chainedTranslator

If you prefer to use nested objects instead of dot notation, you can use chainedTranslator helper.

Loading playground…

It takes a dictionary (not flattened) to map it's shape and a translator function for resolving the translations.

const dict = {
greetings: {
hello: "hello {{ name }}!",
hi: "hi!",
},
goodbye: (name: string) => `goodbye ${name}!`,
};
const flat_dict = i18n.flatten(dict);
const t = i18n.translator(() => flat_dict, i18n.resolveTemplate);
const chained = i18n.chainedTranslator(dict, t);
chained.greetings.hello({ name: "John" }); // => "hello John!"
chained.greetings.hi(); // => "hi!"
chained.goodbye("John"); // => "goodbye John!"

Alternatively you can use proxyTranslator that is implemented using new Proxy so it doesn't require a directory object to be passed as source.

const proxy = i18n.proxyTranslator(t);
proxy.greetings.hello({ name: "John" }); // => "hello John!"
proxy.greetings.hi(); // => "hi!"
proxy.goodbye("John"); // => "goodbye John!"

Using a proxy will have a slight performance impact, so it's recommended to use chainedTranslator if possible. But it can be useful when you don't have access to the dictionary object. Or want to mock the translations in tests.

const proxy = i18n.proxyTranslator(path => path);
proxy.greetings.hello({ name: "John" }); // => "greetings.hello"
proxy.greetings.hi(); // => "greetings.hi"
proxy.goodbye("John"); // => "goodbye"

Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord