# Internationalization

> Multi-language support architecture using use-intl in your SaaS application.

*Canonical: https://makerkit.dev/docs/tanstack-prisma/internationalization/overview*

---

This guide is part of the [Prisma](/prisma) stack docs.

The TanStack Start Prisma SaaS Kit uses [use-intl](https://use-intl.dev/) for internationalization. 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 the kit:

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](#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`:

```typescript
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`). It reads the request and returns the locale by precedence:

```typescript
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:

```typescript
// 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`:

```typescript
/** 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:

- **`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`:

```typescript
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:

```typescript
// 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`):

```typescript
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:

```typescript
import {
  Link,
  localizeUrl,
  deLocalizeUrl,
  getLocaleFromPath,
  isIgnoredPath,
} from '@kit/i18n/navigation';
```

### `@kit/i18n/provider`

Provider for the app:

```typescript
import { I18nProvider } from '@kit/i18n/provider';
```

### `@kit/ui/localized-link`

`LocalizedLink` — a locale-aware `<a>` for internal string/dynamic hrefs (see [URL Prefix Routing](#url-prefix-routing)):

```tsx
import { LocalizedLink } from '@kit/ui/localized-link';

<LocalizedLink href={post.url}>{post.title}</LocalizedLink>;
```

### `@kit/i18n/messages`

The message registry and loader:

```typescript
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 →](./using-translations)
