Internationalization
Multi-language support architecture using use-intl in your SaaS application.
This guide is part of the Drizzle stack docs.
MakerKit uses use-intl for internationalization in TanStack Start. The system provides server-side rendering, type-safe translations, and URL-prefix-aware locale resolution driven by a router rewrite. Routes stay flat — there is no [locale] path segment; the rewrite strips and re-adds the locale prefix at the URL boundary. With the default single-locale setup URLs carry no prefix and the locale comes from a cookie; adding a locale (localePrefix: 'as-needed') activates prefixes for non-default locales, while the application/API surface is never prefixed.
By default, only English is enabled. Adding additional languages requires adding locale codes to the config and creating translation files for each language.
How It Works
The i18n flow in MakerKit:
detectLocale()resolves the active locale on the server viaresolveRequestLocale()— URL prefix →localecookie →Accept-Languageheader → default- Router
rewrite(apps/web/src/lib/i18n/locale-rewrite.ts) strips the locale prefix before route matching (input) and re-adds it for non-default locales when building hrefs (output), so the route tree stays flat and existing<Link>s localize automatically - Request middleware (
apps/web/src/start.ts) puts the resolved locale on the Start context and 308-redirects redundant prefixes (/en/faq→/faq,/es/dashboard→/dashboard) - Root route (
__root.tsx) exposes the locale through route context, sets<html lang={locale}>, and emits per-page<link rel="canonical">+hreflangalternates - Provider (
I18nProvider) makes the locale's messages available to all components viause-intl; components read them withuseTranslations, thegetTranslatorhelper, or theTranscomponent
For the default locale (and single-locale setups) the locale lives in the locale cookie and URLs carry no prefix. Non-default locales are prefixed in the URL (/es/...) by the router rewrite. The app/API surface (dashboard, settings, admin, API/RPC) is excluded from prefixing via ignoredPathsRegex and always resolves the locale from the cookie.
Architecture
The i18n system is split between shared infrastructure and app-specific configuration:
packages/i18n/ # Shared i18n package└── src/ ├── config.ts # Locales, default locale, cookie, localePrefix, ignoredPathsRegex ├── navigation.ts # Link re-export + locale URL helpers ├── provider.tsx # I18nProvider (wraps use-intl IntlProvider) └── messages/ ├── index.ts # Message registry + getMessages(locale) └── en/ # English translations ├── common.json ├── auth.json ├── account.json ├── organizations.json ├── billing.json ├── marketing.json ├── settings.json ├── goodbye.json └── errors.jsonapps/web/src/├── routes/__root.tsx # Resolves locale in beforeLoad, sets <html lang>├── components/root-providers.tsx # Mounts I18nProvider with the locale's messages└── lib/i18n/ ├── i18n.functions.ts # detectLocale() server function ├── resolve-locale.server.ts # resolveRequestLocale() — URL>cookie>header>default ├── locale-rewrite.ts # router rewrite: strip prefix in / add prefix out └── translator.ts # getTranslator() for use outside React renderLocale Resolution
Routes stay flat (no [locale] segment); locale prefixing is handled by the router rewrite (see URL Prefix Routing below), and a request middleware in apps/web/src/start.ts redirects redundant prefixes. The active locale is resolved by resolveRequestLocale() and surfaced through the detectLocale() server function in apps/web/src/lib/i18n/i18n.functions.ts:
import { createServerFn } from '@tanstack/react-start';import { resolveRequestLocale } from './resolve-locale.server';// Precedence: URL prefix → locale cookie → Accept-Language → defaultexport const detectLocale = createServerFn({ method: 'GET' }).handler(() => resolveRequestLocale(),);resolveRequestLocale() lives in the server-only resolve-locale.server.ts (named *.server.ts and guarded by vite/server-leak-guard.ts — this stack does not use import 'server-only', which is Next.js-only). It reads the request and returns the locale by precedence:
export function resolveRequestLocale(): string { const fromPath = getLocaleFromPath(getRequestUrl().pathname); if (fromPath) return fromPath; // /es/... wins const cookie = getCookie(LOCALE_COOKIE); if (isValidLocale(cookie)) return cookie; // sticky preference // ...then Accept-Language, then defaultLocale}The root route calls it in beforeLoad and passes the result down through the route context:
// apps/web/src/routes/__root.tsxexport const Route = createRootRouteWithContext<RouterContext>()({ beforeLoad: async () => { const [theme, locale] = await Promise.all([getServerTheme(), detectLocale()]); return { theme, locale }; }, // ...});The locale resolution order is: URL prefix → locale cookie → Accept-Language header → default locale.
Locale Configuration
Locales and locale behavior are configured in packages/i18n/src/config.ts:
/** Cookie that stores the user's chosen locale. */export const LOCALE_COOKIE = 'locale';/** The default locale, used when none is chosen and as the unprefixed locale. */export const defaultLocale = 'en';/** All supported locales. Extend this list to enable more languages. */export const locales: string[] = [ defaultLocale, // 'es', // 'fr',];/** * How locales appear in URLs: * - `as-needed`: default locale has no prefix, others are prefixed * - `always`: every locale is prefixed * - `never`: no prefix; the locale comes from the cookie only */export const localePrefix: 'as-needed' | 'always' | 'never' = 'as-needed';/** * Routes that never carry a locale prefix and always resolve the locale from * the cookie (the authenticated app surface and API/RPC endpoints). */export const ignoredPathsRegex = /^\/(?:api|rpc|dashboard|settings|admin|accept-invitation)(?:\/|$)/;Settings explained:
| Setting | Value | Effect |
|---|---|---|
LOCALE_COOKIE | 'locale' | Cookie key the chosen locale is stored under |
defaultLocale | 'en' | Used when no cookie/header match; the unprefixed locale |
localePrefix | 'as-needed' | Default locale has no URL prefix, others do |
ignoredPathsRegex | regex | App/API paths that never get a locale prefix |
URL Structure
With localePrefix: 'as-needed', the default locale is unprefixed while non-default locales carry a URL prefix on the public surface; the application surface (matched by ignoredPathsRegex) is never prefixed:
| URL | Locale | Notes |
|---|---|---|
/about | English (default) | No prefix needed |
/es/about | Spanish | Prefix for a non-default locale |
/dashboard | Cookie-resolved | App surface — never prefixed |
The URL helpers in packages/i18n/src/navigation.ts (localizeUrl, deLocalizeUrl, getLocaleFromPath, isIgnoredPath) implement this behavior and honor ignoredPathsRegex.
URL Prefix Routing
Locale prefixes are applied by a router rewrite in apps/web/src/lib/i18n/locale-rewrite.ts, not by a route segment:
inputde-localizes the URL (/es/faq→/faq) before route matching, so the route tree stays flat.outputlocalizes hrefs as they are built, so every TanStack<Link>renders the active locale's URL automatically — no per-link changes needed.
Guidance for links and redirects:
- Typed routes →
<Link>(from@kit/i18n/navigation). The rewrite localizes it for you. - Dynamic/string hrefs (CMS URLs) →
LocalizedLink(from@kit/ui/localized-link). - Never a raw
<a>for internal links — it bypasses the router and won't be localized. - Server-side redirects to public/auth routes localize their target with
localizeUrl(target, locale)(seeapps/web/src/lib/auth/guards.ts); client-side redirects are localized by the rewrite automatically.
A request-middleware guard (apps/web/src/start.ts) 308-redirects redundant prefixes — the default locale (/en/faq → /faq) and prefixed app paths (/es/dashboard → /dashboard). The root route also emits a per-page <link rel="canonical"> plus hreflang alternates for public pages.
All of this is dormant while a single locale is enabled: localizeUrl/deLocalizeUrl no-op, so URLs are byte-identical to a cookie-only setup until you add a second locale.
Provider
The locale's messages are made available to the app through I18nProvider, mounted in apps/web/src/components/root-providers.tsx:
import { I18nProvider } from '@kit/i18n/provider';import { getMessages } from '@kit/i18n/messages';export function RootProviders({ theme, locale, children }) { return ( <I18nProvider locale={locale} messages={getMessages(locale)}> {/* ... */} {children} </I18nProvider> );}I18nProvider wraps the use-intl IntlProvider. Missing keys fall back to the key itself; missing-translation warnings are logged in development only:
// packages/i18n/src/provider.tsximport { IntlProvider, type Messages } from 'use-intl';export function I18nProvider({ locale, messages, timeZone = 'UTC', children }) { return ( <IntlProvider locale={locale} messages={messages} timeZone={timeZone} getMessageFallback={(info) => info.key} onError={(error) => { if (process.env.NODE_ENV !== 'production') { console.warn(`i18n: ${error.message}`); } }} > {children} </IntlProvider> );}Package Exports
The @kit/i18n package provides these entry points:
@kit/i18n / @kit/i18n/config
Locale configuration (the root export re-exports ./config):
import { LOCALE_COOKIE, defaultLocale, locales, localePrefix, ignoredPathsRegex, isValidLocale, type Locale,} from '@kit/i18n/config';@kit/i18n/navigation
Link (re-exported from @tanstack/react-router) plus locale URL helpers:
import { Link, localizeUrl, deLocalizeUrl, getLocaleFromPath, isIgnoredPath,} from '@kit/i18n/navigation';@kit/i18n/provider
Provider for the app:
import { I18nProvider } from '@kit/i18n/provider';@kit/ui/localized-link
LocalizedLink — a locale-aware <a> for internal string/dynamic hrefs (see URL Prefix Routing):
import { LocalizedLink } from '@kit/ui/localized-link';<LocalizedLink href={post.url}>{post.title}</LocalizedLink>;@kit/i18n/messages
The message registry and loader:
import { getMessages, type Messages } from '@kit/i18n/messages';Namespaces
Translation files are organized by namespace. Each namespace is a JSON file under packages/i18n/src/messages/{locale}/, statically imported and registered in packages/i18n/src/messages/index.ts. Unlike a lazy-loading setup, all of a locale's namespaces are bundled and provided at once.
| Namespace | Purpose |
|---|---|
common | Shared UI labels, routes, roles, OTP, cookie banner, doc search |
auth | Sign up, sign in, password reset, MFA flows |
account | Profile updates, email/password management |
organizations | Team management, invitations, member actions |
billing | Subscriptions, plans, billing portal |
marketing | Landing page, blog, FAQ, contact forms |
settings | Personal and organization settings pages |
goodbye | Account deletion confirmation |
errors | Error messages and status codes |
Environment Variables
| Variable | Default | Description |
|---|---|---|
VITE_DEFAULT_LOCALE | 'en' | App-level default language code (see apps/web/src/config/app.config.ts) |
The i18n package's own defaultLocale lives in packages/i18n/src/config.ts.
Error Handling
Missing translations are handled gracefully by I18nProvider:
- In development: warnings are logged to the console
- In production: errors are silently ignored
- Fallback: the translation key is returned as-is (
getMessageFallback)
Next: Using Translations →