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:

  1. detectLocale() resolves the active locale on the server via resolveRequestLocale()URL prefix → locale cookie → Accept-Language header → default
  2. 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
  3. 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)
  4. Root route (__root.tsx) exposes the locale through route context, sets <html lang={locale}>, and emits per-page <link rel="canonical"> + hreflang alternates
  5. Provider (I18nProvider) makes the locale's messages available to all components via use-intl; components read them with useTranslations, the getTranslator helper, or the Trans component

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.json
apps/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 render

Locale 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 → default
export 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.tsx
export 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:

SettingValueEffect
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
ignoredPathsRegexregexApp/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:

URLLocaleNotes
/aboutEnglish (default)No prefix needed
/es/aboutSpanishPrefix for a non-default locale
/dashboardCookie-resolvedApp 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:

  • input de-localizes the URL (/es/faq/faq) before route matching, so the route tree stays flat.
  • output localizes 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) (see apps/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.tsx
import { 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.

NamespacePurpose
commonShared UI labels, routes, roles, OTP, cookie banner, doc search
authSign up, sign in, password reset, MFA flows
accountProfile updates, email/password management
organizationsTeam management, invitations, member actions
billingSubscriptions, plans, billing portal
marketingLanding page, blog, FAQ, contact forms
settingsPersonal and organization settings pages
goodbyeAccount deletion confirmation
errorsError messages and status codes

Environment Variables

VariableDefaultDescription
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 →