---
title: First-Class i18n
phase: 18.μ
status: stable
audience: framework-users, contributors
---

# First-Class i18n

Mandu ships a framework-level internationalization primitive. Declare
your locales once, pick a resolution strategy, and the framework
handles route synthesis, per-request locale detection, typed
translation helpers, and cache correctness for you.

This doc covers the Mandu i18n API (Phase 18.μ). It is the framework
analogue of Next.js App Router i18n and SvelteKit's `paraglide-kit`
integration, except the resolver + message registry are kept small
enough to understand in one sitting.

## Quick start

```ts
// src/i18n.ts
import { defineI18n, defineMessages } from "@mandujs/core/i18n";

export const i18n = defineI18n({
  locales: ["en", "ko"],
  defaultLocale: "en",
  strategy: "path-prefix",
});

export const messages = defineMessages({
  en: { welcome: "Welcome, {{name}}!" },
  ko: { welcome: "환영합니다, {{name}}님!" },
} as const);
```

```ts
// mandu.config.ts
import { i18n } from "./src/i18n";

export default {
  i18n: {
    locales: i18n.locales as string[],
    defaultLocale: i18n.defaultLocale,
    strategy: i18n.strategy,
  },
};
```

```ts
// app/page.tsx
import type { ManduContext } from "@mandujs/core";

export const loader = (ctx: ManduContext) => ({
  greeting: ctx.t?.("welcome", { name: ctx.locale?.code ?? "friend" }),
});

export default function Home({ loaderData }: { loaderData: { greeting: string } }) {
  return <h1>{loaderData.greeting}</h1>;
}
```

A single `app/page.tsx` now serves `/` (default locale), `/ko`,
`/en` — all produced by the manifest-time synthesizer.

## Strategy matrix

| Strategy       | URL shape         | Best for                              | Redirect policy                                                |
|----------------|-------------------|---------------------------------------|----------------------------------------------------------------|
| `path-prefix`  | `/ko/docs`        | SEO-critical sites, Next.js parity    | Default locale unprefixed; others rewritten via 307 on boot    |
| `domain`       | `ko.example.com`  | Separate TLDs / subdomains per locale | None — DNS is canonical                                        |
| `header`       | `/docs`           | APIs, locale-less URLs                | None                                                           |
| `cookie`       | `/docs`           | Explicit opt-in, user settings pane   | None; server reads the cookie every request                    |

Resolution order within each strategy is documented in
`packages/core/src/i18n/locale-resolver.ts`. In brief:

```
path-prefix → URL  → cookie → Accept-Language → default
domain      → Host → cookie → Accept-Language → default
header      → Accept-Language → cookie → default
cookie      → Cookie → Accept-Language → default
```

Invalid signals fall through to the next tier — never a 5xx.

## Context API

Every request carries `ctx.locale` (a `ResolvedLocale`) and, when you
supplied `messages`, `ctx.t` (a typed `Translator`). Both are
`undefined` when `ManduConfig.i18n` is omitted, so the hot path stays
branch-free for projects that opt out.

```ts
interface ResolvedLocale {
  code: string;            // e.g., "en", "ko", "en-US"
  isDefault: boolean;      // true when we used `defaultLocale`
  strategy: "path-prefix" | "domain" | "header" | "cookie"
          | "default" | "fallback";
  source?: string;         // raw input that produced the match
}
```

```ts
ctx.t("welcome", { name: "Mandu" });
// ↑ rejects typos in "welcome" at compile time when messages was
//   declared with `as const`.
```

Missing keys walk `activeLocale → fallbackLocale → defaultLocale` and
finally surface the raw key — making QA bugs visible without crashing.

## Response headers

For every request that resolved a locale, Mandu stamps:

- `Content-Language: <code>` — SEO + screen reader parity.
- `Vary: Accept-Language, Cookie` — CDNs and intermediate caches now
  key on the locale signal. Merges with existing `Vary` values
  (tracing, auth, etc.) without clobbering them.

## Path-prefix route synthesis

When `strategy === "path-prefix"`, the manifest builder runs
`synthesizeLocaleRoutes(routes, { locales, defaultLocale })` during
`mandu build` / `mandu dev`. Given `app/docs/page.tsx` and
`locales: ["en", "ko"]`, the manifest carries:

- `/docs` (default locale, unprefixed for SEO + legacy links)
- `/ko/docs` (synthesized — same `module` path, new `id`: `ko::docs`)

Metadata routes (sitemap.xml, robots.txt, llms.txt, manifest.webmanifest)
are NEVER duplicated — they stay at site root regardless of locale
(same convention as Next.js).

## Migrating from manual `app/[lang]/...`

Many projects (including the mandujs.com docs site) pre-date i18n
support and use the file-convention hack:

```
app/
├─ [lang]/
│  ├─ docs/
│  │  └─ page.tsx       ← reads ctx.params.lang
│  └─ layout.tsx
```

With 20+ pages × 2 locales this means 40+ manually-maintained files
whose `ctx.params.lang` switch dominates every loader. Migration:

### 1. Declare i18n

```ts
// src/i18n.ts
export const i18n = defineI18n({
  locales: ["en", "ko"],
  defaultLocale: "en",
  strategy: "path-prefix",
});
```

### 2. Flatten the tree

```
app/
├─ docs/
│  └─ page.tsx          ← read ctx.locale?.code instead
└─ layout.tsx
```

### 3. Swap `ctx.params.lang` for `ctx.locale.code`

```diff
- const lang = ctx.params.lang ?? "en";
+ const lang = ctx.locale?.code ?? "en";
```

### 4. Serve both routing conventions during migration

Both strategies coexist: if some pages have moved and others haven't,
the manifest builder synthesizes prefixes only for the flat pages.
The legacy `[lang]/` tree continues to match explicit URLs until you
finish the migration.

## Comparison

| Feature                      | Next.js App Router | SvelteKit (paraglide) | **Mandu (Phase 18.μ)** |
|------------------------------|--------------------|------------------------|------------------------|
| Config surface               | `i18n.ts` + `middleware.ts` | `paraglide.config.ts` | `defineI18n()` + single config block |
| Path-prefix synthesis        | Manual routing    | Rewrite middleware     | Automatic at manifest build |
| `ctx.t()` / `m.*()`         | No (use `next-intl` etc.) | `m.welcome({ name })` | `ctx.t("welcome", { name })` typed |
| Compile-time key check       | Third-party       | Yes (codegen)          | Yes (`as const` inference) |
| Vary header auto-stamp       | Manual            | Manual                 | Automatic              |
| Domain / host-based strategy | Manual            | Manual                 | First-class            |
| Cookie-based strategy        | Manual            | Manual                 | First-class            |

## Reference

- `defineI18n(config)` — validate + brand an i18n config.
- `defineMessages(bundles)` — branded message registry with key inference.
- `createTranslator(registry, opts)` — bind a `t()` to a locale.
- `resolveLocale(request, config)` — pure locale resolver.
- `synthesizeLocaleRoutes(routes, opts)` — build-time prefix synthesis.
- `isI18nDefinition(v)` / `isMessageRegistry(v)` — type guards.

Source: [packages/core/src/i18n/](../../packages/core/src/i18n/) +
[packages/core/src/router/fs-scanner.ts](../../packages/core/src/router/fs-scanner.ts).
