
Frontend Internationalization Best Practices
- 173 installs
- 93 repo stars
- Updated February 1, 2026
- sergiodxa/agent-skills
Implement i18n in React or similar frontends with locale routing, message catalogs, RTL, and accessible translated UI.
About
Best practices for frontend internationalization: namespace organization, lazy-loaded locales, ICU-style plurals, RTL layouts, SEO-friendly hreflang, and testing translated strings without layout breakage across markets.
- Locale routing strategy
- Message catalog structure
- RTL and typography
- Plural and date formatting
- SSR and hydration safety
Frontend Internationalization Best Practices by the numbers
- 173 all-time installs (skills.sh)
- +3 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #915 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sergiodxa/agent-skills --skill frontend-internationalization-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 173 |
|---|---|
| repo stars | ★ 93 |
| Last updated | February 1, 2026 |
| Repository | sergiodxa/agent-skills ↗ |
What it does
Implement i18n in React or similar frontends with locale routing, message catalogs, RTL, and accessible translated UI.
Files
Internationalization Best Practices
Guidelines for building a React Router i18n setup with remix-i18next. Focuses on middleware detection, locale storage, type safety, and client/server synchronization.
When to Apply
- Adding i18n to a React Router app
- Wiring
remix-i18nextmiddleware - Implementing language switching or locale detection
- Serving locale resources from
/api/locales
Rules Summary
Setup & Middleware (CRITICAL)
setup-middleware - @rules/setup-middleware.md
Configure createI18nextMiddleware and type-safe resources.
export const [i18nextMiddleware, getLocale, getInstance] =
createI18nextMiddleware({
detection: {
supportedLanguages: ["es", "en"],
fallbackLanguage: "en",
cookie: localeCookie,
},
i18next: { resources },
plugins: [initReactI18next],
});locales-structure - @rules/locales-structure.md
Define locale resources per language and re-export.
// app/locales/en/translation.ts
export default { title: "Example" };Namespaces (HIGH)
namespaces-strategy - @rules/namespaces-strategy.md
Use a single namespace for small apps; multiple namespaces for large apps.
// Large app: common + route namespaces
export default { common, home, notFound };Locale Detection & Persistence (CRITICAL)
locale-detection - @rules/locale-detection.md
Prefer cookie/session for speed, with DB as source of truth.
export const [i18nextMiddleware, getLocale] = createI18nextMiddleware({
detection: { cookie: localeCookie, fallbackLanguage: "en" },
});language-switcher - @rules/language-switcher.md
Store locale in cookie/session and keep it in sync.
return data(
{ locale },
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } },
);Client & Server Integration (CRITICAL)
root-locale-sync - @rules/root-locale-sync.md
Send locale to the UI and sync <html lang dir>.
export async function loader({ context }: Route.LoaderArgs) {
let locale = getLocale(context);
return data(
{ locale },
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } },
);
}entry-client-init - @rules/entry-client-init.md
Initialize i18next client with htmlTag detection.
i18next.init({ detection: { order: ["htmlTag"], caches: [] } });entry-server-provider - @rules/entry-server-provider.md
Reuse the middleware instance in SSR with I18nextProvider.
<I18nextProvider i18n={getInstance(routerContext)}>
<ServerRouter context={entryContext} url={request.url} />
</I18nextProvider>Resource Routes & Caching (HIGH)
locales-resource-route - @rules/locales-resource-route.md
Serve /api/locales/:lng/:ns with validation and cache headers.
return data(namespaces[ns.data], { headers });UI Usage (MEDIUM)
use-bound-t-in-loader - @rules/use-bound-t-in-loader.md
Use the bound t() in loaders and useTranslation in components.
let t = getInstance(context).getFixedT(locale);Not Found (MEDIUM)
not-found-i18n - @rules/not-found-i18n.md
Provide a 404 route so middleware runs and translations load.
Initialize i18next on the Client
Use the HTML lang attribute as the client detection source and load namespaces from /api/locales.
Pattern
// app/entry.client.tsx
import Fetch from "i18next-fetch-backend";
import i18next from "i18next";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { HydratedRouter } from "react-router/dom";
import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
async function main() {
await i18next
.use(initReactI18next)
.use(Fetch)
.use(I18nextBrowserLanguageDetector)
.init({
fallbackLng: "en",
detection: { order: ["htmlTag"], caches: [] },
backend: { loadPath: "/api/locales/{{lng}}/{{ns}}" },
});
startTransition(() => {
hydrateRoot(
document,
<I18nextProvider i18n={i18next}>
<StrictMode>
<HydratedRouter />
</StrictMode>
</I18nextProvider>,
);
});
}
main().catch((error) => console.error(error));Rules
1. Detect locale from htmlTag only (server already decided) 2. Use /api/locales/{{lng}}/{{ns}} for resource loading 3. Keep fallbackLng aligned with middleware
Reuse i18next Instance on the Server
Wrap SSR with I18nextProvider using the instance created by the middleware.
Pattern
// app/entry.server.tsx
import type { EntryContext, RouterContextProvider } from "react-router";
import { ServerRouter } from "react-router";
import { I18nextProvider } from "react-i18next";
import { getInstance } from "~/middleware/i18next";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
entryContext: EntryContext,
routerContext: RouterContextProvider,
) {
return (
<I18nextProvider i18n={getInstance(routerContext)}>
<ServerRouter context={entryContext} url={request.url} />
</I18nextProvider>
);
}Rules
1. Use getInstance(routerContext) from the middleware 2. Avoid creating a new i18next instance on the server
Build a Language Switcher
Persist locale in cookie or session so users keep their preference across requests.
Pattern
// app/routes/_index.tsx
import { data } from "react-router";
import type { Route } from "./+types/_index";
import { localeCookie, getLocale } from "~/middleware/i18next";
export async function loader({ context }: Route.LoaderArgs) {
let locale = getLocale(context);
return data(
{ locale },
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } },
);
}export function LanguageSwitcher() {
return (
<div>
<a href="/?lng=en">English</a>
<a href="/?lng=es">Espanol</a>
</div>
);
}Rules
1. Store locale in cookie/session when user switches languages 2. Keep URLs stable; use query params or UI state for the switch 3. Prefer cookie/session over DB lookups on each request
Prefer Cookie/Session Locale with DB Source of Truth
Use cookie or session for fast locale access, and fall back to DB when needed.
Why
- Avoid DB lookups on every request
- Keep a stable locale across server and client
- Still honor user preferences stored in DB
Pattern
// app/middleware/i18next.ts
export const [i18nextMiddleware, getLocale] = createI18nextMiddleware({
detection: {
supportedLanguages: ["es", "en"],
fallbackLanguage: "en",
cookie: localeCookie,
async findLocale(request) {
let locale = await getLocaleFromSession(request);
if (locale) return locale;
let userLocale = await getLocaleFromDatabase(request);
return userLocale ?? "en";
},
},
i18next: { resources },
});Optional: Pathname Locale
If you want /en/... routes, you can use findLocale to read the first path segment. This is optional and not recommended as the default storage.
findLocale(request) {
let locale = new URL(request.url).pathname.split("/")[1];
return locale;
}Rules
1. Read locale from cookie/session for speed 2. Use DB as the source of truth when present 3. Only use pathname locales if your routing strategy requires it
Serve Locales from a Resource Route
Expose /api/locales/:lng/:ns to load translation resources with caching.
Pattern
// app/routes/api.locales.$lng.$ns.ts
import { data } from "react-router";
import { cacheHeader } from "pretty-cache-header";
import { z } from "zod";
import resources from "~/locales";
import type { Route } from "./+types/api.locales.$lng.$ns";
export async function loader({ params }: Route.LoaderArgs) {
const lng = z
.enum(Object.keys(resources) as Array<keyof typeof resources>)
.safeParse(params.lng);
if (lng.error) return data({ error: lng.error }, { status: 400 });
const namespaces = resources[lng.data];
const ns = z
.enum(Object.keys(namespaces) as Array<keyof typeof namespaces>)
.safeParse(params.ns);
if (ns.error) return data({ error: ns.error }, { status: 400 });
const headers = new Headers();
if (process.env.NODE_ENV === "production") {
headers.set(
"Cache-Control",
cacheHeader({
maxAge: "5m",
sMaxage: "1d",
staleWhileRevalidate: "7d",
staleIfError: "7d",
}),
);
}
return data(namespaces[ns.data], { headers });
}Rules
1. Validate lng and ns before returning data 2. Cache locale resources in production 3. Keep the route aligned with client loadPath
Structure Locales by Language
Organize translations under app/locales/{lng} and re-export them in a single resource map.
Why
- Keeps locale resources explicit and discoverable
- Enables type-safe parity checks between languages
- Makes server and client loaders consistent
Pattern
// app/locales/en/translation.ts
export default {
title: "Example",
description: "A React Router + remix-i18next example",
};
// app/locales/en/index.ts
import type { ResourceLanguage } from "i18next";
import translation from "./translation";
export default { translation } satisfies ResourceLanguage;// app/locales/es/translation.ts
export default {
title: "Ejemplo",
description: "Un ejemplo de React Router + remix-i18next",
} satisfies typeof import("~/locales/en/translation").default;
// app/locales/es/index.ts
import type { ResourceLanguage } from "i18next";
import translation from "./translation";
export default { translation } satisfies ResourceLanguage;// app/locales/index.ts
import type { Resource } from "i18next";
import en from "./en";
import es from "./es";
export default { en, es } satisfies Resource;Rules
1. Keep en as the default source of truth for keys 2. Use satisfies to enforce parity between locales 3. Export a single resources object for middleware
Choose a Namespace Strategy
Use a single namespace for small apps, and multiple namespaces for large apps.
Why
- Single namespace is simplest for small codebases
- Multiple namespaces keep large apps modular and faster to load
- Makes route-level translations easier to reason about
Single Namespace (Small Apps)
// app/locales/en/translation.ts
export default {
home: {
title: "Home",
description: "Welcome",
},
settings: {
title: "Settings",
},
};Multiple Namespaces (Large Apps)
// app/locales/en/common.ts
export default { appName: "Example" };
// app/locales/en/home.ts
export default { title: "Home" };
// app/locales/en/index.ts
import type { ResourceLanguage } from "i18next";
import common from "./common";
import home from "./home";
export default { common, home } satisfies ResourceLanguage;Rules
1. Use a single translation namespace for small apps 2. Use per-route namespaces (home, notFound, etc.) for large apps 3. Keep namespace names stable to simplify caching
Localize the 404 Route
Provide a not-found route so the middleware runs and the i18n instance is configured.
Pattern
// app/routes/not-found.tsx
import { data, Link } from "react-router";
import { useTranslation } from "react-i18next";
export async function loader() {
return data(null, { status: 404 });
}
export default function Component() {
let { t } = useTranslation("notFound");
return (
<div>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
<Link to="/">{t("backToHome")}</Link>
</div>
);
}Rules
1. Return a 404 response in the loader 2. Use useTranslation with a not-found namespace 3. Ensure the 404 route is registered so middleware runs
Sync Locale in Root Loader and HTML
Return the detected locale from the root loader and keep <html lang dir> in sync.
Why
- Ensures server and client use the same locale
- Sets correct
langanddirattributes for accessibility - Keeps i18next client instance aligned with the server
Pattern
import { data, Outlet } from "react-router";
import { useEffect } from "react";
import type { Route } from "./+types/root";
import { useTranslation } from "react-i18next";
import { getLocale, i18nextMiddleware, localeCookie } from "~/middleware/i18next";
export const middleware = [i18nextMiddleware];
export async function loader({ context }: Route.LoaderArgs) {
let locale = getLocale(context);
return data(
{ locale },
{ headers: { "Set-Cookie": await localeCookie.serialize(locale) } },
);
}
export function Layout({ children }: { children: React.ReactNode }) {
let { i18n } = useTranslation();
return (
<html lang={i18n.language} dir={i18n.dir(i18n.language)}>
<body>{children}</body>
</html>
);
}
export default function App({ loaderData: { locale } }: Route.ComponentProps) {
let { i18n } = useTranslation();
useEffect(() => {
if (i18n.language !== locale) i18n.changeLanguage(locale);
}, [i18n, locale]);
return <Outlet />;
}Rules
1. Always return locale from the root loader 2. Set lang and dir on <html> 3. Sync client i18n language in a root useEffect
Set Up remix-i18next Middleware
Use createI18nextMiddleware as the single source of locale detection and i18next configuration.
Why
- Centralizes locale detection for loaders and actions
- Reuses one i18next instance per request
- Enables type-safe
t()across the app
Pattern
// app/middleware/i18next.ts
import { initReactI18next } from "react-i18next";
import { createCookie } from "react-router";
import { createI18nextMiddleware } from "remix-i18next/middleware";
import resources from "~/locales";
import "i18next";
export const localeCookie = createCookie("lng", {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
});
export const [i18nextMiddleware, getLocale, getInstance] =
createI18nextMiddleware({
detection: {
supportedLanguages: ["es", "en"],
fallbackLanguage: "en",
cookie: localeCookie,
},
i18next: { resources },
plugins: [initReactI18next],
});
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "translation";
resources: typeof resources.en;
}
}Rules
1. Use middleware as the only locale detection entry point 2. Keep supportedLanguages and fallbackLanguage explicit 3. Add CustomTypeOptions for typed t() in TS
Use the Bound t() in Loaders
Use the middleware instance’s bound t() in loaders and keep UI hooks for components.
Pattern
import { getInstance } from "~/middleware/i18next";
export async function loader({ context }: Route.LoaderArgs) {
let t = getInstance(context).t;
return { title: t("title"), description: t("description") };
}import { useTranslation } from "react-i18next";
export default function Component() {
let { t } = useTranslation();
return <h1>{t("title")}</h1>;
}Rules
Namespaced t()
If you need a specific namespace, use getFixedT with the current locale and namespace:
export async function loader({ context }: Route.LoaderArgs) {
let i18n = getInstance(context);
let t = i18n.getFixedT(i18n.language, "notFound");
return { title: t("title") };
}Rules
1. Use the bound t() from getInstance(context) in loaders 2. Use getFixedT(locale, namespace) when you need a specific namespace 3. Use useTranslation in components for client rendering 4. Avoid mixing different locales in the same loader