An Inertia app renders on the client, but its translations can still live on the server, next to the Go code: one set of JSON files in resources/lang, used by html/template views, validation messages, mail and your components. This guide wires that up in an Inertia starter. The reference for each piece is in Localization and Inertia.
How it fits together#
vukem.WithTranslationsloadsresources/lang/*.jsonat boot.vukem.DefaultMiddlewarethen installsi18n.Middleware, which picks the request's locale (?lang=, thelangcookie, the session,Accept-Language, the default).inertia.I18n(prefixes...)shares three props with every page:locale,localesandmessages.- A ten-line
translatehelper on the client reads them.
Step by step#
1. Write the catalogs
{
"nav": { "home": "Home", "posts": "Posts" },
"ui": {
"welcome": "Welcome, {name}!",
"posts": { "count": { "zero": "No posts", "one": "{count} post", "other": "{count} posts" } }
}
}{
"nav": { "home": "Início", "posts": "Posts" },
"ui": {
"welcome": "Bem-vindo, {name}!",
"posts": { "count": { "zero": "Nenhum post", "one": "{count} post", "other": "{count} posts" } }
}
}Keys missing from pt-BR.json fall back to the default locale (APP_LOCALE, en by default).
2. Load them at boot
app, err := vukem.New(
vukem.WithTranslations(os.DirFS("resources/lang")), // bad JSON or file names fail New
vukem.WithInertia(inertia.Config{}),
// ...
)To ship the catalogs inside the binary, pass an embedded fs.FS (for example fs.Sub of an embed.FS) instead of os.DirFS.
3. Share them with every page
The starter's routes/web.go already calls DefaultMiddleware (which now resolves the locale) and installs the Inertia middleware. Add inertia.I18n after both:
if err := vukem.DefaultMiddleware(app, r); err != nil {
return err
}
r.Use(
app.Inertia.Middleware,
inertia.I18n("nav.", "ui."), // only these keys reach the browser
shareProps(app),
)4. Add the client helper
type Message = string | Record<string, string>
export function translate(
messages: Record<string, Message>,
locale: string,
key: string,
params: Record<string, string | number> = {},
): string {
let msg = messages[key]
if (msg === undefined) return key
if (typeof msg !== "string") {
const n = Number(params.count)
msg = (n === 0 && msg.zero) || msg[new Intl.PluralRules(locale).select(n)] || msg.other || key
}
return msg.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m))
}messages arrives with flat keys ("ui.welcome"); plural messages are objects keyed by CLDR category, the same rules the server's i18n.T uses.
5. Use it in your components
import { usePage } from "@inertiajs/react"
import { translate } from "@/lib/i18n"
type I18nProps = { messages: Record<string, string>; locale: string; locales: string[] }
export function useT() {
const { messages, locale } = usePage<I18nProps>().props
return (key: string, params = {}) => translate(messages, locale, key, params)
}import { useT } from "@/lib/use-t"
export default function Home({ user, postCount }: { user: { name: string }; postCount: number }) {
const t = useT()
return (
<>
<h1>{t("ui.welcome", { name: user.name })}</h1>
<p>{t("ui.posts.count", { count: postCount })}</p>
</>
)
}6. Add a language switcher
A plain link with ?lang= switches the locale for that request:
const { locales } = usePage<I18nProps>().props
{locales.map((l) => (
<Link key={l} href={`?lang=${l}`} preserveScroll>{l}</Link>
))}To remember the choice, set the lang cookie (or the session value locale) on the server, for example in a small route:
r.Post("/locale", func(c *web.Ctx) error {
l, ok := app.I18n.Match(c.FormValue("locale")) // only locales that have a catalog
if !ok {
return errs.ErrNotFound
}
c.SetCookie(&http.Cookie{
Name: "lang", Value: l, Path: "/", MaxAge: 365 * 24 * 3600,
HttpOnly: true, Secure: app.Config.IsProduction(), SameSite: http.SameSiteLaxMode,
})
return c.Back("/")
}).Name("locale.update")What else follows the locale#
<html lang>: the starters' root view writes<html lang="{{or locale "en"}}">, so screen readers and the browser's translate prompt see the right language.- Validation errors:
web.Parsemessages are translated from thevalidation.*keys of your catalogs, so theerrorsyour forms show (useForm().errors) are already in the user's language. - Flash messages and mail translated with
i18n.T(c.Context(), "key")use the same locale.
Pitfalls#
- Order matters.
inertia.I18nmust run after the locale is resolved and after the Inertia middleware; installed before them, pages get the default locale or no props at all. - Pass prefixes.
inertia.I18n()with no prefix sends the whole catalog to the browser, including server-only texts (mail, admin). List the prefixes the UI needs. messagesis a once prop. The client keeps it until the locale changes, so a deploy with new keys is picked up on the next full page load or locale switch; Inertia's asset version check already forces a reload after a new build.- A switcher that only links to
?lang=forgets the choice on the next navigation without the parameter. Set the cookie as in step 6. - Plural keys need
count.t("ui.posts.count")without{ count }falls back to theotherform.
Security notes#
- Translations are plain text. React, Vue and Svelte escape them; never render them with
dangerouslySetInnerHTML,v-htmlor{@html}, especially when a placeholder holds user input. - The locale from
?lang=, cookies andAccept-Languageis validated against the available catalogs; invalid values are ignored, so they cannot reach file paths or responses.