# i18n Foundation — Design Spec

**Date:** 2026-06-12
**Status:** Approved (design) — ready for implementation plan
**Sub-project:** 1 of 4 (i18n → onboarding wizard → Stripe billing → agency domain whitelisting)

## Goal

Introduce internationalization to SEO Insight using **next-intl** without URL-prefix routing. The locale is a **per-user preference** (`user.locale`, default `en`), resolved server-side from cookie → `Accept-Language` → default. Externalize the **foundation surfaces** (auth, nav, common) into message catalogs for `en` (source of truth), `fr`, `es`, `de`. The rest of the app migrates page-by-page later (project's incremental method). This unblocks sub-project 2 (the onboarding wizard) so it is born localized, and ships a reusable `LanguageSelector`.

## Decisions (locked)

- **Library:** `next-intl` (App Router native: server components, `useTranslations`/`getTranslations`, ICU formatting, type-safe).
- **Routing:** **No URL prefix.** This is an authenticated SaaS — no per-locale SEO need. Locale comes from a custom source (cookie + DB), not the URL. No i18n routing middleware.
- **Default locale:** `en`. Supported set: `['en', 'fr', 'es', 'de']`. Architecture must allow adding `ja` later by editing one config array + adding one catalog file — nothing else.
- **Persistence:** new `user.locale` column (default `'en'`), exposed via Better Auth `user.additionalFields.locale`. Anonymous visitors use cookie `NEXT_LOCALE`; once authenticated, `user.locale` is authoritative and rewrites the cookie.
- **Migration scope:** foundation only — `Auth`, `Nav`, `Common` namespaces. `Onboarding` namespace is created here (empty/seed) and filled in sub-project 2. Un-migrated pages stay hardcoded French until their page-by-page turn (an `en` user will see French on un-migrated pages — accepted transitional state).

## Out of scope (explicit)

- Migrating the whole app's strings (later, page-by-page).
- The onboarding wizard itself (sub-project 2) — only the `Onboarding` namespace stub + reusing `LanguageSelector`.
- Changing `<html lang>` in `src/app/layout.tsx`: it intentionally reflects the **audited site's** language with `translate="no"` (prevents the Chrome auto-translate React crash). UI locale is provided separately via `NextIntlClientProvider`. Leave `<html lang>` untouched.
- Stripe / plans / agency whitelisting.

## Architecture

### Config — single source of truth
`src/i18n/config.ts`
```ts
export const locales = ['en', 'fr', 'es', 'de'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export function isLocale(value: string): value is Locale {
  return (locales as readonly string[]).includes(value);
}
```
Adding `ja` later = append to `locales` + add `messages/ja.json`. Nothing else.

### Locale resolution
`src/i18n/locale.ts` (server-only)
- `getUserLocale(): Promise<Locale>` — priority:
  1. authenticated session → `user.locale` (if a valid `Locale`),
  2. else cookie `NEXT_LOCALE` (if a valid `Locale`),
  3. else negotiate `Accept-Language` header against `locales`,
  4. else `defaultLocale`.
- `setUserLocale(locale: Locale): Promise<void>` — **server action**:
  - validate via `isLocale` (throw on invalid),
  - set cookie `NEXT_LOCALE` (1 year, `sameSite: 'lax'`, `path: '/'`),
  - if authenticated, persist `user.locale` to DB,
  - `revalidatePath('/', 'layout')`.
- `negotiateAcceptLanguage(header: string | null): Locale` — pure helper (quality-value aware, case-insensitive, base-language match e.g. `fr-CA` → `fr`), unit-testable in isolation.

Cookie name is the next-intl convention `NEXT_LOCALE`.

### Request config
`src/i18n/request.ts`
```ts
import { getRequestConfig } from 'next-intl/server';
import { getUserLocale } from './locale';

export default getRequestConfig(async () => {
  const locale = await getUserLocale();
  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
```
Wire the next-intl plugin in `next.config.ts` via `createNextIntlPlugin('./src/i18n/request.ts')` wrapping the existing config (preserve all current options: `reactCompiler`, `serverExternalPackages`, `experimental.optimizePackageImports`, `logging`).

### Provider
`src/app/layout.tsx`: wrap the body subtree in `NextIntlClientProvider`. With next-intl ≥ 3.x App Router, messages are provided to client components automatically via the request config; pass `locale` explicitly from `getUserLocale()` so client `useTranslations` has the active locale. Do **not** change `<html lang>`.

### Catalogs
`messages/en.json`, `messages/fr.json`, `messages/es.json`, `messages/de.json`. Namespaced:
```json
{
  "Common": { "save": "Save", "cancel": "Cancel", "loading": "Loading…", "...": "..." },
  "Auth":   { "signUp": "Sign up", "...": "..." },
  "Nav":    { "...": "..." },
  "Onboarding": {}
}
```
`en.json` is the source of truth. `fr/es/de` mirror its key structure. `Onboarding` starts as `{}` in all four (filled in sub-project 2).

### Type safety
`src/i18n/messages.d.ts` augments next-intl with the `en.json` shape so `useTranslations`/`getTranslations` keys are typed:
```ts
import type messages from '../../messages/en.json';
declare module 'next-intl' {
  interface AppConfig { Messages: typeof messages; }
}
```

### Language selector
`src/components/i18n/language-selector.tsx` (client) — shadcn `Select` listing `locales` with native language labels (`English`, `Français`, `Español`, `Deutsch`). On change → call `setUserLocale`. Shows pending state during the transition. Reused as-is in nav and (later) the wizard.
- Mount in `nav-user` and Settings.
- Labels for each locale come from a small `localeLabels: Record<Locale, string>` map in `src/i18n/config.ts`.

## Data model

Add to `src/lib/db/auth-schema.ts` `user` table:
```ts
locale: text("locale").default("en").notNull(),
```
Add to `src/lib/auth.ts`:
```ts
user: {
  additionalFields: {
    locale: { type: "string", required: false, defaultValue: "en", input: true },
  },
},
```
Migration `0029` in `./drizzle` (`ALTER TABLE "user" ADD COLUMN "locale" text DEFAULT 'en' NOT NULL`).

**⚠️ Journal `when` gotcha:** the auto-generated `when` for `0029` will be a real `Date.now()` smaller than `0028`'s fabricated `1781290000000`. After `db:generate`, manually set `0029`'s `when` to **`1781300000000`** in `drizzle/meta/_journal.json` (must exceed the previous max, else Drizzle skips it in prod). Run `db:migrate` **and** `db:migrate:test`. Never `db:push`.

## Migrated surfaces (this chantier)

Replace hardcoded French with `t('...')` calls, adding the strings to all four catalogs (`fr` = the current French text, `en` = English source, `es`/`de` = translations):

- **Auth** (`Auth` namespace): `src/components/signup-form.tsx`, signup page, login form, verify-email page, `accept-invitation/[id]` page.
- **Nav** (`Nav` namespace): `src/components/app-sidebar.tsx`, `nav-user`.
- **Common** (`Common` namespace): shared buttons/labels surfaced while migrating the above (save/cancel/loading/etc.).

Server components → `getTranslations('Namespace')`; client components → `useTranslations('Namespace')`.

## Testing (gate: `bun run test:all`)

1. **`negotiateAcceptLanguage`** (pure unit): `'fr-CA,fr;q=0.9,en;q=0.8'` → `fr`; `'ja'` (unsupported) → `en`; `''`/`null` → `en`; quality ordering respected; base-language match.
2. **`getUserLocale`** priority: cookie present & valid → that; cookie invalid → falls through; authenticated `user.locale` overrides cookie. (Inject/mocked session + cookie store.)
3. **`setUserLocale`**: invalid locale throws; valid sets cookie; authenticated path persists `user.locale` to DB (assert against test DB — guard with the project's `assertTestDb()` pattern; force `DATABASE_URL=...seo_insight_test`).
4. **Catalog parity**: load `en/fr/es/de` JSON; assert every key path present in `en` exists in `fr`, `es`, `de` for the foundation namespaces (`Common`, `Auth`, `Nav`). Fails build if a translation key is missing.
5. **Render**: a migrated component (e.g. signup form) renders the `fr` string under `fr` locale and the `en` string under `en` (next-intl test provider).

## File structure

```
src/i18n/config.ts            # locales, defaultLocale, isLocale, localeLabels  (new)
src/i18n/locale.ts            # getUserLocale, setUserLocale, negotiateAcceptLanguage  (new)
src/i18n/request.ts           # getRequestConfig  (new)
src/i18n/messages.d.ts        # type augmentation  (new)
messages/{en,fr,es,de}.json   # catalogs  (new)
src/components/i18n/language-selector.tsx   # selector  (new)
next.config.ts                # wrap with createNextIntlPlugin  (modify)
src/app/layout.tsx            # NextIntlClientProvider  (modify, <html lang> untouched)
src/lib/db/auth-schema.ts     # + user.locale  (modify)
src/lib/auth.ts               # + user.additionalFields.locale  (modify)
drizzle/0029_*.sql + meta     # migration  (new, when bumped to 1781300000000)
src/components/signup-form.tsx, app-sidebar.tsx, nav-user, login/verify/accept pages  (modify)
```

## Risks / notes

- **Default `en` vs French app:** un-migrated pages remain French; an `en` user sees mixed language until those pages migrate. Accepted; tracked by the page-by-page backlog.
- **`db:generate` may overwrite the bumped `when`** — re-check `0029`'s `when` after any regeneration.
- **Better Auth `additionalFields`**: confirm `input: true` so the wizard (sub-project 2) can set `locale` at/after signup; the org-bootstrap hook must not clobber it.
- **next-intl version:** install latest; verify the "without i18n routing" + `getRequestConfig` API against the installed version's docs before coding (APIs evolve).
