Solid Primitives i18n (Solid)
Combine Solid Primitives i18n with TanStack i18n locale routing. Build reactive, localized user interfaces in SolidJS applications.
Supported Frameworks: Solid
Runnable References:
- Solid Start (SSR): solid-start-solid-primitives-i18n
- Solid Router (SPA): solid-router-solid-primitives-i18n
@solid-primitives/i18n is a lightweight, reactive translation primitive library for SolidJS. TanStack i18n handles URL locale routing, client/server entry state, cookies, and programmatic transitions — @solid-primitives/i18n handles dictionary-based translation rendering in your Solid components.
This guide explains how to split translation catalogs into namespaces, merge and flatten them reactively with @solid-primitives/i18n, expose a fully typesafe single-signature hook supporting nested paths, and resolve select-element hydration states.
Prerequisites
- Guides through Change locale
- Locale runtime exports
locale - TanStack Start server entry wraps
handler.fetch(if using Start)
Responsibility split
| Layer | Owns | Package |
|---|---|---|
URL locale, redirects, cookie, setLocale | Routing + persist | @Wadiou/tanstack-i18n |
| Dictionary translation primitives, flat keys | Translation | @solid-primitives/i18n |
Add Message Files
Organize your translation strings inside structured namespace-specific files per locale:
// messages/en/common.json
{
"aboutTitle": "About us",
"aboutBody": "We build bilingual marketing sites with TanStack i18n and solid-primitives-i18n.",
"switchLanguage": "Language"
}// messages/en/landing.json
{
"hero": "Welcome to our site",
"homeLink": "Home",
"aboutLink": "About us",
"programmaticHome": "Programmatic Home"
}And define corresponding translations in messages/ar/common.json and messages/ar/landing.json.
Implement loadMessages
Dynamic import per locale keeps your production bundle size optimized:
// src/i18n/load-messages.ts
import { flatten } from "@solid-primitives/i18n";
import type { SupportedLocale } from "@/constants/locales";
export async function loadMessages(locale: SupportedLocale) {
const [common, landing] = await Promise.all([
import(`../../messages/${locale}/common.json`),
import(`../../messages/${locale}/landing.json`),
]);
return flatten({
Common: common.default,
Landing: landing.default,
}) as Record<string, string>;
}Implement useTranslations Hook
Create a unified, type-safe hook matching namespaced keys.
To satisfy TypeScript's index signature constraint for generic dictionary flattening while fully complying with Biome linter guidelines (which discourage the use of type aliases for object shapes), define Messages as an interface and wrap it in a mapped type inside the type-helper FlatMessages:
// src/i18n/use-translations.ts
import type {
Flatten,
Scoped,
Scopes,
Translator,
} from "@solid-primitives/i18n";
import {
resolveTemplate,
scopedTranslator,
translator,
} from "@solid-primitives/i18n";
import { Route } from "@/routes/__root";
import type enCommon from "../../messages/en/common.json";
import type enLanding from "../../messages/en/landing.json";
export interface Messages {
Common: typeof enCommon;
Landing: typeof enLanding;
}
export type FlatMessages = Flatten<{ [K in keyof Messages]: Messages[K] }>;
export function useTranslations<
TNamespace extends
| Scopes<keyof FlatMessages & string>
| undefined = undefined,
>(
namespace?: TNamespace
): Translator<
TNamespace extends string ? Scoped<FlatMessages, TNamespace> : FlatMessages
> {
const context = Route.useRouteContext();
const t = translator(
() => context().messages as unknown as FlatMessages,
resolveTemplate
);
type ReturnType = Translator<
TNamespace extends string ? Scoped<FlatMessages, TNamespace> : FlatMessages
>;
if (namespace) {
return scopedTranslator(t, namespace) as unknown as ReturnType;
}
return t as unknown as ReturnType;
}Ensure your tsconfig.json includes "messages/**/*" in its "include" section so TypeScript can resolve types for imported .json files correctly.
Root beforeLoad — locale + messages
Fetch and bundle active locale messages in the router context in beforeLoad on src/routes/__root.tsx:
// src/routes/__root.tsx
import { createRootRouteWithContext } from "@tanstack/solid-router";
import { loadMessages } from "@/i18n/load-messages";
import { getLocale } from "@/locale";
export interface RouterContext {
locale: string;
messages: Record<string, string>;
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async () => {
const active = await getLocale();
const messages = await loadMessages(active);
return { locale: active, messages };
},
});Create the Locale Provider
Expose a bridge provider that links your active router context to @Wadiou/tanstack-i18n bindings:
// src/i18n/use-locale.ts
import { Route } from "@/routes/__root";
export function useLocale() {
return Route.useRouteContext()().locale;
}// src/i18n/provider.tsx
import { createLocaleProvider } from "@Wadiou/tanstack-i18n/solid";
import { locale } from "@/locale";
import { useLocale } from "./use-locale";
export const { LocaleProvider, useLocaleContext } = createLocaleProvider({
runtime: locale,
useLocale,
});Wrap the provider in your root layout:
// src/routes/__root.tsx
import { Outlet } from "@tanstack/solid-router";
import { LocaleProvider } from "@/i18n/provider";
function RootComponent() {
return (
<LocaleProvider>
<Outlet />
</LocaleProvider>
);
}Render Translations
Consume your namespaced translators in pages:
// src/routes/{-$locale}/about.tsx
import { createFileRoute } from "@tanstack/solid-router";
import { useTranslations } from "@/i18n/use-translations";
export const Route = createFileRoute("/{-$locale}/about")({
component: AboutPage,
});
function AboutPage() {
const t = useTranslations("Common");
return (
<div>
<h1>{t("aboutTitle")}</h1>
<p>{t("aboutBody")}</p>
</div>
);
}Implement Language Switcher
Expose a dropdown switcher reactively bound to useLocaleContext and SolidJS's createSelector to avoid hydration selection state mismatches:
// src/components/header.tsx
import { createSelector, For } from "solid-js";
import { useLocaleContext } from "@/i18n/provider";
import { useTranslations } from "@/i18n/use-translations";
export function Header() {
const localeContext = useLocaleContext();
const tCommon = useTranslations("Common");
const isSelected = createSelector(() => localeContext.locale);
return (
<header>
<label for="locale-select">{tCommon("switchLanguage")}</label>
<select
id="locale-select"
onChange={(e) => {
localeContext.setLocale(e.currentTarget.value);
}}
value={localeContext.locale}
>
<For each={["en", "ar"]}>
{(code) => (
<option selected={isSelected(code)} value={code}>
{code === "en" ? "English" : "العربية"}
</option>
)}
</For>
</select>
</header>
);
}SPA vs. SSR Layouts
Depending on your router choice, adapt how you handle document text direction (rtl vs. ltr) and lang attributes:
- Single Page Application (SPA): Since the DOM is always loaded, apply updates directly inside
beforeLoadon the client:beforeLoad: async () => { const active = await getLocale(); const messages = await loadMessages(active); document.documentElement.setAttribute("lang", active); document.documentElement.setAttribute("dir", active === "ar" ? "rtl" : "ltr"); return { locale: active, messages }; } - Server Side Rendering (SSR): In Solid Start, bind the root
<html>attributes dynamically in your root document layout based on the active router context:// src/routes/__root.tsx function RootDocument(props: { children: JSX.Element; locale: string }) { return ( <html dir={props.locale === "ar" ? "rtl" : "ltr"} lang={props.locale}> {/* ... */} </html> ); }