# i18n Foundation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add next-intl (no URL routing) with per-user locale (`user.locale`, default `en`), resolved from cookie → Accept-Language → default; externalize auth/nav/common surfaces into `en/fr/es/de` catalogs; ship a reusable `LanguageSelector`.

**Architecture:** Locale is a user preference, not a URL segment. `getRequestConfig` reads it server-side via `getUserLocale()` (session `user.locale` → cookie `NEXT_LOCALE` → `Accept-Language` → `en`). `NextIntlClientProvider` provides messages to client components. `<html lang>` is NOT changed (intentionally the audited site's language).

**Tech Stack:** Next 16.2.7, React 19.2.7, next-intl (latest), Better Auth 1.6.14, Drizzle, Postgres, bun test.

**Spec:** `docs/superpowers/specs/2026-06-12-i18n-foundation-design.md`

**Conventions:** Conventional Commits; commits end with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. Explicit `git add <paths>` only — NEVER `git add -A` (there is an untracked `docs/project/bug-fix.md` that must never be staged). Branch `feature/i18n-foundation` already exists and is checked out. Install deps with `sfw bun add <pkg>`. Never `db:push`. Gate before each commit where code changed: `bun run test:all`.

---

### Task 1: Install next-intl + base config + provider (render path)

**Files:**
- Create: `src/i18n/config.ts`, `src/i18n/request.ts`, `messages/en.json`
- Modify: `next.config.ts`, `src/app/layout.tsx`, `package.json` (via install)

- [ ] **Step 1: Install next-intl**

Run: `sfw bun add next-intl`
Verify it resolves against Next 16 (next-intl ≥ 4 supports Next 15/16). Before writing config, skim the installed version's "App Router without i18n routing" + `getRequestConfig` docs (APIs evolve) — `node_modules/next-intl/dist` or https://next-intl.dev.

- [ ] **Step 2: Create the locale 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 const localeLabels: Record<Locale, string> = {
  en: "English",
  fr: "Français",
  es: "Español",
  de: "Deutsch",
};

export function isLocale(value: string | null | undefined): value is Locale {
  return value != null && (locales as readonly string[]).includes(value);
}
```

- [ ] **Step 3: Create a minimal English catalog**

`messages/en.json`:
```json
{
  "Common": { "save": "Save", "cancel": "Cancel", "loading": "Loading…" },
  "Auth": {},
  "Nav": {},
  "Onboarding": {}
}
```

- [ ] **Step 4: Create the request config**

`src/i18n/request.ts`:
```ts
import { getRequestConfig } from "next-intl/server";
import { defaultLocale } from "./config";

export default getRequestConfig(async () => {
  // getUserLocale() arrives in Task 3; until then resolve to default so the
  // render path compiles and works.
  const locale = defaultLocale;
  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
```

- [ ] **Step 5: Wire the next-intl plugin into next.config.ts**

Wrap the existing exported config (preserve ALL current options). `next.config.ts`:
```ts
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* ...unchanged existing config (reactCompiler, serverExternalPackages,
     experimental.optimizePackageImports, logging)... */
};

const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
export default withNextIntl(nextConfig);
```
Keep every existing field — only change the default export to be wrapped.

- [ ] **Step 6: Add NextIntlClientProvider to the root layout**

`src/app/layout.tsx`: import `getLocale` and `NextIntlClientProvider` from next-intl; wrap the existing provider tree. Do NOT touch `<html lang={lang}>` or `translate="no"`.
```tsx
import { NextIntlClientProvider } from "next-intl";
// ...inside RootLayout, wrap the body children:
<NextIntlClientProvider>
  <ThemeProvider ...>
    <NuqsAdapter>{children}</NuqsAdapter>
    <Toaster />
  </ThemeProvider>
</NextIntlClientProvider>
```
(In App Router, `NextIntlClientProvider` with no props inherits locale+messages from the request config.)

- [ ] **Step 7: Verify build/typecheck and gate**

Run: `bun run test:all`
Expected: PASS (no tests changed yet; this confirms the wiring compiles).
Also run `bun run lint` (biome) — fix any issues.

- [ ] **Step 8: Commit**

```bash
git add src/i18n/config.ts src/i18n/request.ts messages/en.json next.config.ts src/app/layout.tsx package.json bun.lock
git commit -m "feat(i18n): scaffold next-intl (no URL routing) + provider"
```

---

### Task 2: DB — user.locale column + Better Auth additionalFields + migration 0029

**Files:**
- Modify: `src/lib/db/auth-schema.ts`, `src/lib/auth.ts`
- Create: `drizzle/0029_*.sql` + meta updates

- [ ] **Step 1: Add the column to the Drizzle schema**

`src/lib/db/auth-schema.ts`, in the `user` table, after `image`:
```ts
locale: text("locale").default("en").notNull(),
```

- [ ] **Step 2: Expose it via Better Auth additionalFields**

`src/lib/auth.ts`, inside `betterAuth({ ... })`, add (or extend) a `user` key:
```ts
user: {
  additionalFields: {
    locale: { type: "string", required: false, defaultValue: "en", input: true },
  },
},
```
Ensure the org-bootstrap `user.create` hook does not overwrite `locale`.

- [ ] **Step 3: Generate the migration**

Run: `bun run db:generate`
Expected: a new `drizzle/0029_*.sql` containing `ALTER TABLE "user" ADD COLUMN "locale" text DEFAULT 'en' NOT NULL;`

- [ ] **Step 4: Fix the journal `when` (CRITICAL)**

Open `drizzle/meta/_journal.json`. The new `idx: 29` entry's `when` will be a real timestamp SMALLER than `0028`'s `1781290000000`. Set `0029`'s `when` to exactly `1781300000000`. (Must exceed previous max or Drizzle skips it in prod.)

- [ ] **Step 5: Apply to dev + test DBs**

Run: `bun run db:migrate`
Run: `bun run db:migrate:test`
Expected: both report the migration applied. (If you see a transient "column does not exist" under the live dev server, re-run — pooled connections lag.)

- [ ] **Step 6: Gate + commit**

Run: `bun run test:all` → PASS
```bash
git add src/lib/db/auth-schema.ts src/lib/auth.ts drizzle/0029_*.sql drizzle/meta/_journal.json drizzle/meta/0029_snapshot.json
git commit -m "feat(i18n): add user.locale column + Better Auth additionalField"
```

---

### Task 3: Locale resolution (locale.ts) + tests

**Files:**
- Create: `src/i18n/locale.ts`, `src/i18n/negotiate.ts`, `src/i18n/negotiate.test.ts`
- Modify: `src/i18n/request.ts`

- [ ] **Step 1: Write the failing test for Accept-Language negotiation**

`src/i18n/negotiate.test.ts`:
```ts
import { describe, expect, it } from "bun:test";
import { negotiateAcceptLanguage } from "./negotiate";

describe("negotiateAcceptLanguage", () => {
  it("picks the highest-quality supported base language", () => {
    expect(negotiateAcceptLanguage("fr-CA,fr;q=0.9,en;q=0.8")).toBe("fr");
  });
  it("falls back to en for unsupported languages", () => {
    expect(negotiateAcceptLanguage("ja,zh;q=0.9")).toBe("en");
  });
  it("returns en for empty/null", () => {
    expect(negotiateAcceptLanguage("")).toBe("en");
    expect(negotiateAcceptLanguage(null)).toBe("en");
  });
  it("respects quality ordering over position", () => {
    expect(negotiateAcceptLanguage("en;q=0.5,de;q=0.9")).toBe("de");
  });
  it("matches base language case-insensitively", () => {
    expect(negotiateAcceptLanguage("ES-es")).toBe("es");
  });
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `bun run test src/i18n/negotiate.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Implement the pure negotiator**

`src/i18n/negotiate.ts`:
```ts
import { type Locale, defaultLocale, isLocale } from "./config";

export function negotiateAcceptLanguage(header: string | null): Locale {
  if (!header) return defaultLocale;
  const ranked = header
    .split(",")
    .map((part) => {
      const [tag, ...params] = part.trim().split(";");
      const q = params
        .map((p) => p.trim())
        .find((p) => p.startsWith("q="));
      const quality = q ? Number.parseFloat(q.slice(2)) : 1;
      return { base: tag.trim().toLowerCase().split("-")[0], quality };
    })
    .filter((r) => r.base && !Number.isNaN(r.quality))
    .sort((a, b) => b.quality - a.quality);
  for (const { base } of ranked) {
    if (isLocale(base)) return base;
  }
  return defaultLocale;
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `bun run test src/i18n/negotiate.test.ts` → PASS

- [ ] **Step 5: Implement getUserLocale + setUserLocale**

`src/i18n/locale.ts`:
```ts
"use server";

import { cookies, headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db/client";
import { user as userTable } from "@/lib/db/auth-schema";
import { type Locale, defaultLocale, isLocale } from "./config";
import { negotiateAcceptLanguage } from "./negotiate";

const COOKIE = "NEXT_LOCALE";

export async function getUserLocale(): Promise<Locale> {
  const session = await auth.api.getSession({ headers: await headers() });
  const sessionLocale = (session?.user as { locale?: string } | undefined)?.locale;
  if (isLocale(sessionLocale)) return sessionLocale;

  const cookieLocale = (await cookies()).get(COOKIE)?.value;
  if (isLocale(cookieLocale)) return cookieLocale;

  const accept = (await headers()).get("accept-language");
  return negotiateAcceptLanguage(accept);
}

export async function setUserLocale(locale: Locale): Promise<void> {
  if (!isLocale(locale)) throw new Error(`Invalid locale: ${locale}`);
  (await cookies()).set(COOKIE, locale, {
    path: "/",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 365,
  });
  const session = await auth.api.getSession({ headers: await headers() });
  if (session?.user?.id) {
    await db
      .update(userTable)
      .set({ locale })
      .where(eq(userTable.id, session.user.id));
  }
  revalidatePath("/", "layout");
}
```
Note: this file is a server action module (`"use server"`); both exports are async. If `getUserLocale` cannot be a server action (it's called from `request.ts`), split it into `src/i18n/locale.ts` (plain server-only module with `getUserLocale`) and `src/i18n/locale-actions.ts` (`"use server"` with `setUserLocale`). Choose the split that compiles; prefer the split — `request.ts` must import `getUserLocale` from a non-`"use server"` module.

- [ ] **Step 6: Wire getUserLocale into request.ts**

`src/i18n/request.ts`: replace the hardcoded `defaultLocale` with `await getUserLocale()`.

- [ ] **Step 7: Gate + commit**

Run: `bun run test:all` → PASS
```bash
git add src/i18n/locale.ts src/i18n/negotiate.ts src/i18n/negotiate.test.ts src/i18n/request.ts
# include src/i18n/locale-actions.ts if you split
git commit -m "feat(i18n): locale resolution (cookie/Accept-Language/user.locale) + setUserLocale"
```

---

### Task 4: Catalogs (en/fr/es/de) + parity test + type augmentation

**Files:**
- Create: `messages/fr.json`, `messages/es.json`, `messages/de.json`, `src/i18n/catalogs.test.ts`, `src/i18n/messages.d.ts`
- Modify: `messages/en.json` (add the full Auth/Nav/Common keys this chantier needs)

- [ ] **Step 1: Populate en.json with the foundation keys**

Add to `messages/en.json` all `Common`, `Auth`, `Nav` keys the migrated surfaces will use (Tasks 5–7). Derive the exact key set by reading the strings in `signup-form.tsx`, the login/verify/accept pages, `app-sidebar.tsx`, `nav-user.tsx`. `Onboarding` stays `{}`. Example shape:
```json
{
  "Common": { "save": "Save", "cancel": "Cancel", "loading": "Loading…", "continue": "Continue" },
  "Auth": {
    "signUpTitle": "Create your account",
    "name": "Name",
    "email": "Email",
    "password": "Password",
    "signUp": "Sign up",
    "haveAccount": "Already have an account?",
    "logIn": "Log in"
  },
  "Nav": { "settings": "Settings", "account": "Account", "team": "Team", "logOut": "Log out", "support": "Support" },
  "Onboarding": {}
}
```

- [ ] **Step 2: Create fr/es/de with the SAME key structure**

`messages/fr.json` = current French strings (the app's existing copy). `messages/es.json`, `messages/de.json` = translations. Every key present in `en.json` must exist in all three.

- [ ] **Step 3: Write the catalog parity test**

`src/i18n/catalogs.test.ts`:
```ts
import { describe, expect, it } from "bun:test";
import { locales } from "./config";
import en from "../../messages/en.json";
import fr from "../../messages/fr.json";
import es from "../../messages/es.json";
import de from "../../messages/de.json";

const catalogs: Record<string, unknown> = { en, fr, es, de };

function keyPaths(obj: unknown, prefix = ""): string[] {
  if (obj == null || typeof obj !== "object") return [prefix];
  return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
    keyPaths(v, prefix ? `${prefix}.${k}` : k),
  );
}

describe("message catalog parity", () => {
  const enKeys = new Set(keyPaths(en).filter((k) => !k.startsWith("Onboarding")));
  for (const loc of locales) {
    if (loc === "en") continue;
    it(`${loc}.json has every en foundation key`, () => {
      const locKeys = new Set(keyPaths(catalogs[loc]));
      const missing = [...enKeys].filter((k) => !locKeys.has(k));
      expect(missing).toEqual([]);
    });
  }
});
```

- [ ] **Step 4: Run the parity test**

Run: `bun run test src/i18n/catalogs.test.ts` → PASS (fix any missing keys until green).

- [ ] **Step 5: Add type augmentation**

`src/i18n/messages.d.ts`:
```ts
import type messages from "../../messages/en.json";

declare module "next-intl" {
  interface AppConfig {
    Messages: typeof messages;
  }
}
```

- [ ] **Step 6: Gate + commit**

Run: `bun run test:all` → PASS
```bash
git add messages/en.json messages/fr.json messages/es.json messages/de.json src/i18n/catalogs.test.ts src/i18n/messages.d.ts
git commit -m "feat(i18n): en/fr/es/de catalogs + parity test + typed messages"
```

---

### Task 5: LanguageSelector component + mount points

**Files:**
- Create: `src/components/i18n/language-selector.tsx`
- Modify: `src/components/nav-user.tsx`, the Settings page that should host it

- [ ] **Step 1: Build the selector**

`src/components/i18n/language-selector.tsx` (client). Use the shadcn `Select` already in the project. Read the current locale via `useLocale()` from next-intl; on change call `setUserLocale` inside `startTransition`; show pending state.
```tsx
"use client";
import { useLocale } from "next-intl";
import { useTransition } from "react";
import { locales, localeLabels, type Locale } from "@/i18n/config";
import { setUserLocale } from "@/i18n/locale-actions"; // or wherever setUserLocale lives
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";

export function LanguageSelector() {
  const current = useLocale() as Locale;
  const [pending, startTransition] = useTransition();
  return (
    <Select
      value={current}
      disabled={pending}
      onValueChange={(v) => startTransition(() => { void setUserLocale(v as Locale); })}
    >
      <SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
      <SelectContent position="popper">
        {locales.map((loc) => (
          <SelectItem key={loc} value={loc}>{localeLabels[loc]}</SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}
```
(Recall the project gotcha: use `position="popper"` on `SelectContent`.)

- [ ] **Step 2: Mount it**

Add `<LanguageSelector />` to `nav-user` (in the dropdown) and to the Settings/Compte page. Match existing layout/spacing.

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` → PASS; `bun run lint` clean.
```bash
git add src/components/i18n/language-selector.tsx src/components/nav-user.tsx src/app/\(app\)/settings/**
git commit -m "feat(i18n): LanguageSelector in nav-user + settings"
```

---

### Task 6: Migrate Auth surfaces to t()

**Files:**
- Modify: `src/components/signup-form.tsx`, login form, verify-email page, `src/app/accept-invitation/[id]/page.tsx`

- [ ] **Step 1: Replace hardcoded strings with the `Auth`/`Common` namespaces**

Client components: `const t = useTranslations("Auth")`. Server components: `const t = await getTranslations("Auth")`. Replace every user-facing French literal with `t("key")`, adding any missing key to all four catalogs first (keep the parity test green).

- [ ] **Step 2: Verify catalogs still parity-pass**

Run: `bun run test src/i18n/catalogs.test.ts` → PASS

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` → PASS
```bash
git add src/components/signup-form.tsx src/app/login/** src/app/verify-email/** src/app/accept-invitation/** messages/*.json
git commit -m "feat(i18n): localize auth surfaces (signup/login/verify/accept)"
```

---

### Task 7: Migrate Nav surfaces to t()

**Files:**
- Modify: `src/components/app-sidebar.tsx`, `src/components/nav-user.tsx`

- [ ] **Step 1: Replace nav labels with the `Nav` namespace**

`const t = useTranslations("Nav")` (these are client components). Replace sidebar item labels, user-menu labels (Settings/Compte/Équipe/Support/Log out) with `t("...")`, adding keys to all four catalogs.

- [ ] **Step 2: Verify parity + gate**

Run: `bun run test src/i18n/catalogs.test.ts` → PASS
Run: `bun run test:all` → PASS

- [ ] **Step 3: Commit**

```bash
git add src/components/app-sidebar.tsx src/components/nav-user.tsx messages/*.json
git commit -m "feat(i18n): localize sidebar + user-menu nav labels"
```

---

## Self-review notes

- **Spec coverage:** config/routing (T1), data model (T2), resolution (T3), catalogs+parity+types (T4), selector (T5), auth surfaces (T6), nav surfaces (T7). All spec sections covered.
- **`<html lang>`:** left untouched per spec.
- **`when` bump:** explicit in T2 step 4 (`1781300000000`).
- **`getUserLocale` vs `"use server"`:** T3 step 5 flags the split (`request.ts` needs a non-action import). Implementer chooses the compiling split; downstream imports (`locale-actions.ts`) noted in T5.
- **No `git add -A`:** every commit uses explicit paths.
