--- createdAt: 2026-09-02 updatedAt: 2026-09-02 title: "Solid i18n: why translations freeze on locale change" description: Why Solid components capture translations as constants, how @solid-primitives/i18n works, lazy catalogs, SolidStart locale routing, and what Intlayer changes. keywords: - solidjs i18n - solid start i18n - solid internationalization - solid-primitives i18n - solid-i18next - fine-grained reactivity - locale routing - Intlayer slugs: - blog - i18n-technologies - frameworks - solid author: aymericzip --- # Solid i18n: the translation that never updates Solid's reactivity model changes what an i18n library has to do, and it changes the bugs you get. The most common one is a page that switches language everywhere except in the three components where someone stored a translation in a `const`. This post explains why that happens, then covers the real options: `@solid-primitives/i18n`, lazy catalogs, SolidStart locale routing, and Intlayer. ## Table of Contents ## The bug you will hit first Here is a component that looks correct and is not. ```tsx fileName="src/components/CartSummary.tsx" import { useI18n } from "~/i18n"; export const CartSummary = () => { const t = useI18n(); const total = t("cart.total"); // evaluated once, during setup return (

{total}

{t("cart.items", { count: 3 })}

); }; ``` Switch the locale and the `

` updates. The `

` does not. It stays in whatever language was active the first time the component ran, and it will stay there until the component is destroyed and recreated. In React this code works, because a locale change re-renders the component and re-evaluates every line of its body. In Solid nothing re-runs, so the difference between the two lines is real and permanent. ## Why: components run once A Solid component is a factory, not a render function. It executes exactly once, returns DOM nodes, and is never called again. What updates afterwards is not the component, it is the individual reactive computations Solid's compiler created inside the JSX. `{t("cart.items", { count: 3 })}` inside JSX compiles to a getter that Solid wraps in an effect. Reading the locale signal inside it subscribes that one text node. `const total = t("cart.total")` runs in the setup body, outside any tracking scope, so it subscribes to nothing and produces a plain string. This is the same rule that forbids destructuring props in Solid. Applied to i18n, it means every translated value must stay behind a function call that is executed inside a tracked scope. The fix is to keep the read lazy: ```tsx fileName="src/components/CartSummary.tsx" import { createMemo } from "solid-js"; import { useI18n } from "~/i18n"; export const CartSummary = () => { const t = useI18n(); const total = createMemo(() => t("cart.total")); // an accessor, not a string return

{total()}

; }; ``` Two consequences worth internalizing: - A translation you pass to a non-JSX API (a `document.title` assignment, a chart config, an `aria-label` computed in setup) is a snapshot. It needs its own effect or memo. - A translation stored in a store, a module-level variable or a closure created at setup is a snapshot too. Locale changes will not reach it. ## `@solid-primitives/i18n` is the community default The Solid ecosystem is small, and this is the option most projects land on. It is not a framework, it is a set of primitives: you own the dictionaries, the locale signal, and the loading strategy. ```ts fileName="src/i18n/index.ts" import * as i18n from "@solid-primitives/i18n"; import { createSignal } from "solid-js"; const en = { cart: { total: "Total", items: "{{ count }} items" }, }; export type RawDictionary = typeof en; export type Dictionary = i18n.Flatten; const [locale, setLocale] = createSignal<"en" | "fr">("en"); const dictionary = () => i18n.flatten(en); const t = i18n.translator(dictionary, i18n.resolveTemplate); t("cart.items", { count: 3 }); // "3 items" ``` `flatten` turns the nested object into `"cart.total"` style keys, and `translator` takes an _accessor_, not a value. That signature is the whole design: because the dictionary arrives as a function, `t` reads it inside your tracking scope, and the resulting text is reactive as long as you call `t` in a reactive position. Types are derived from the English dictionary, so a typo in a key is a compile error without any extra codegen. That is a genuine advantage over `t("a.b.c")` in most other libraries. What it does not give you: no namespace scoping, no route-aware splitting, no cookie handling, no locale routing, no formatters. On a 10 page app that is fine. On a 40 page app you are writing that layer yourself. ## Lazy loading catalogs Because `translator` accepts an accessor that may return `undefined`, a resource plugs straight in. ```ts fileName="src/i18n/index.ts" import * as i18n from "@solid-primitives/i18n"; import { createResource, createSignal } from "solid-js"; const fetchDictionary = async (locale: string): Promise => { const module = await import(`./locales/${locale}.ts`); return i18n.flatten(module.dictionary); }; const [locale, setLocale] = createSignal("en"); const [dictionary] = createResource(locale, fetchDictionary); export const t = i18n.translator(dictionary, i18n.resolveTemplate); ``` During the fetch, `t` returns `undefined` and your UI renders empty strings unless you wrap it in a `` or supply a fallback. That transient blank is the price of the split, and it is easy to miss in development where the import resolves instantly. This splits by locale, not by page. Every route still pays for every other route's copy. Getting per-page splitting out of a flat dictionary means maintaining one file per page per locale by hand. ## SolidStart: the locale has to be resolved on the server Client-side locale detection does not work for SSR. If the server renders in English and the client then reads a cookie and switches to French, you get a hydration mismatch and a visible flash. The reliable source of truth is the URL, because the server and the client both see it. SolidStart's file router supports an optional dynamic segment, and `[[locale]]` compiles to `:locale?`: ```plaintext src/routes/ [[locale]].tsx layout that validates the segment [[locale]]/ index.tsx / /fr /es about.tsx /about /fr/about /es/about [...404].tsx ``` The catch is that `@solidjs/router` expands `:locale?` into two patterns and tries them by specificity. Without a match filter, `/unknown` matches the locale segment and silently renders your home page with `locale="unknown"` instead of a 404. Constrain it in the layout's `route` export: ```tsx fileName="src/routes/[[locale]].tsx" import type { RouteSectionProps } from "@solidjs/router"; import { locales } from "intlayer"; export const route = { matchFilters: { locale: locales }, }; export default function LocaleLayout(props: RouteSectionProps) { return <>{props.children}; } ``` Two more server-side pieces are yours to write with any library: setting `` and `dir` in `entry-server.tsx`, which sits outside the `Router`, and emitting `canonical` plus `hreflang` links. At the time of writing, `@solidjs/meta` tags are applied on the client after hydration and are not in the server-rendered `` in SolidStart v2, so anything a crawler must see without JavaScript belongs in `entry-server.tsx`. The [hreflang guide](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/internationalization_and_SEO.md) covers what those tags need to say. ## The options | Library | Model | Types on keys | Notes | | :----------------------- | :------------------------------------------------- | :---------------------------------- | :------------------------------------------------------------- | | `@solid-primitives/i18n` | Flat dictionary you own | Inferred from the source dictionary | Very small, no scoping, no routing, no formatters | | `solid-i18next` | i18next catalogs and namespaces | Manual declaration | Mature plugin ecosystem, heaviest of the set | | `@inlang/paraglide-js` | Generated message functions | Generated | Compiled messages, but a regeneration step before every commit | | Intlayer | One `.content.ts` per component, compiled at build | Generated, on by default | Requires a build plugin, smaller ecosystem | The [Solid i18n benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/solid.md) measures these on a 10 page, 10 locale app. `solid-i18next@17.0.2` weighs about **14.6 kB** after bundling and minification, roughly **4.7x `solid-intlayer`**. Paraglide's advertised tree-shaking did not take effect in that implementation, and it reads the locale from storage on each node rather than from a signal, which adds work on every render. ## Intlayer: content next to the component Intlayer's design choice is that content lives in a file beside the component, and a build plugin compiles those declarations into per-component dictionaries. ```ts fileName="src/components/cartSummary.content.ts" import { type Dictionary, plural, t } from "intlayer"; const cartSummaryContent = { key: "cart-summary", content: { total: t({ en: "Total", fr: "Total", es: "Total" }), items: plural({ one: t({ en: "{{count}} item", fr: "{{count}} article" }), other: t({ en: "{{count}} items", fr: "{{count}} articles" }), }), }, } satisfies Dictionary; export default cartSummaryContent; ``` ```tsx fileName="src/components/CartSummary.tsx" import { useIntlayer } from "solid-intlayer"; import { createSignal } from "solid-js"; export const CartSummary = () => { const [count, setCount] = createSignal(3); const content = useIntlayer("cart-summary"); return (

{content.total}

{content.items(count())}

); }; ``` `useIntlayer` returns reactive nodes backed by a signal, so a locale change updates only the DOM nodes that read them, with no component re-run. The same Solid rule still applies: `{content.total}` in JSX is tracked, `const title = content.total.value` in the setup body is a frozen string. Use `.value` for string attributes, and wrap it in a memo if it has to react.