# I18N

> Internationalization for the Next.js 16 App Router without an i18n framework — [locale] segment routing, server-only dictionary pattern, Accept-Language negotiation in proxy.ts, hreflang via metadata alternates, Intl date/number formatting, RTL awareness — plus the discipline to skip all of it when the brief targets a single market. Invoke when design/BRIEF.md names two or more languages or markets, or when the user mentions translations, multilingual, locales, hreflang, language switcher, RTL, or "add a German/French/Spanish version". Also covers German/DACH typesetting (guillemets, ß/ẞ, DIN-5008 spacing, comma decimals) and the Leichte-Sprache plain-language register for civic/foundation DACH builds.

- Skill: `blyatiful1/i18n` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/i18n`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/i18n/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/i18n

---


# i18n — locales without a framework

**Stage:** Phase 10 — Findability (only when BRIEF.md names 2+ markets) - **Reads:** design/BRIEF.md (markets, languages), design/SITEMAP.md - **Writes:** app/[locale]/ tree, dictionaries/, lib/i18n.ts, proxy.ts negotiation, hreflang alternates

## When NOT to i18n — decide this first

- `design/BRIEF.md` names ONE market → skip this skill entirely. A German-only site is German copy (`ultraweb:copywriting`), not i18n infrastructure.
- "Might add English later" → still skip. A `[locale]` segment retrofits in under an hour; speculative scaffolding taxes every route today. Note the option in the handoff README, build nothing.
- 2–4 locales of marketing content → the pattern below, zero libraries. An i18n framework (ICU plurals, extraction tooling) is app-scale machinery; if the brief genuinely needs it, verify against current docs first and pick deliberately.

## Standard

Locale lives in the URL (`/de/arbeiten`), never only in a cookie. Every shipped locale is 100% written — a half-translated locale is worse than none. Dictionaries never reach the client bundle. hreflang is complete, including `x-default`. All dates and numbers go through `Intl` with an explicit locale. The longest locale (German runs ~30% longer than English) is the one verified at 375px. A `de` build is typeset to German rules — `„…"` quotes, `hyphens:auto`, DIN-5008 spacing — not English with umlauts; a civic or foundation `de` brief also owes a Leichte-Sprache register.

## Process

1. **Locale registry** — one shared module, no framework:

```ts
// lib/i18n.ts
export const locales = ["en", "de"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
```

2. **Segment**: move all pages under `app/[locale]/`; its layout is the root layout and owns `<html>`. Params are Promises in Next 16:

```tsx
// app/[locale]/layout.tsx
import { locales, type Locale } from "@/lib/i18n";

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function RootLayout({ children, params }: {
  children: React.ReactNode;
  params: Promise<{ locale: Locale }>;
}) {
  const { locale } = await params;
  return <html lang={locale}>{/* body */}</html>;
}
```

3. **Dictionaries** — server-only, lazy, typed against the default locale:

```ts
// dictionaries/index.ts
import "server-only";
import type { Locale } from "@/lib/i18n";

const dictionaries = {
  en: () => import("./en").then((m) => m.default),
  de: () => import("./de").then((m) => m.default),
};

export const getDictionary = (locale: Locale) => dictionaries[locale]();
```

Write dictionaries as `.ts` modules, not JSON — parity becomes a type error:

```ts
// dictionaries/de.ts — a missing or extra key fails tsc
import type en from "./en";
export default { nav: { work: "Arbeiten", about: "Über uns" } } satisfies typeof en;
```

4. **Pages consume**: `const { locale } = await params; const t = await getDictionary(locale);` — pass `t.nav`, `t.hero` down as props. Leaf client components receive strings as props and never import dictionaries; `server-only` throws at build time if they try.
5. **Negotiation in proxy.ts** — `middleware.ts` is deprecated in Next 16:

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { locales, defaultLocale } from "@/lib/i18n";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (pathname.startsWith("/_next") || pathname.startsWith("/api") || pathname.includes(".")) return;
  if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return;
  const preferred = (request.headers.get("accept-language") ?? "").split(",")[0]?.trim().split("-")[0];
  const locale = locales.find((l) => l === preferred) ?? defaultLocale;
  return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}
```

Redirect, never rewrite — the locale must be visible, shareable, and crawlable.

6. **hreflang** via metadata alternates (requires the `metadataBase` that `ultraweb:seo` sets). Every locale variant lists ALL variants including itself, plus `x-default` pointing at the default locale:

```ts
alternates: {
  canonical: `/${locale}/work`,
  languages: { en: "/en/work", de: "/de/work", "x-default": "/en/work" },
},
```

7. **Switcher**: header links to the SAME route with the segment swapped — `ultraweb:navigation` places it as a designed element. Never a dropdown that reloads to the homepage.
8. **Formatting** — `Intl` is the library; pass the locale explicitly, always:

```ts
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date);
new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(price);
```

Bare `toLocaleDateString()` uses the runtime's locale; server and client disagree and you get a hydration mismatch.

## RTL awareness

Only when an RTL locale (ar, he, fa, ur) is actually in the brief: set `dir` on `<html>` per locale, and write ALL spacing and alignment with logical utilities from day one — `ps-*`/`pe-*`, `ms-*`/`me-*`, `text-start`/`text-end` — never `pl`/`pr`/`ml`/`mr`/`text-left`/`text-right`. shadcn `init --rtl` covers the primitives. Retrofitting physical properties to logical is a full-codebase sweep; deciding up front is free.

## German & DACH typesetting

Only when a `de` locale (`de`, `de-DE`, `de-AT`, `de-CH`) is in the brief — German is not English with umlauts. Set these on the `:lang(de)` prose containers; let `Intl` do the numbers. A Berlin roastery's `de-DE` shop carries `„Kaffee aus Leidenschaft"` and `4,50 €` end to end.

- **Hyphenate the compounds.** `Rechtsschutzversicherung` rags brutally and overflows heroes unbroken. `hyphens: auto` on prose — it relies on the `lang` the `[locale]` layout already sets. Keep it off display type (auto-hyphenated heroes read cheap); break those with a soft hyphen `&shy;` at a chosen seam or `text-wrap: balance`.
- **Quotation glyphs.** German quotes are `„…"` (open low, close high — `„Kaffee aus Leidenschaft"`), not `"…"`. The editorial register uses guillemets `»…«` (inward-pointing; Swiss `de-CH` uses `«…»`). Copywriting writes the real glyphs into the dictionary; set the property for any `<q>`: `:lang(de){ quotes:"„" "“" "‚" "‘" }`.
- **ß, ẞ, and de-CH.** `de-DE`/`de-AT` keep `ß`; Switzerland (`de-CH`) never uses it — always `ss`. In an uppercase headline decide the cap deliberately: `text-transform: uppercase` emits `SS` (STRASSE), while the modern capital `ẞ` (STRAẞE) only renders if the font ships U+1E9E — verify the glyph or keep SS.
- **Numbers, money, dates — through `Intl`, never by hand.** Decimals are a comma, thousands a period (DIN-5008 prefers a thin space in tables): `Intl.NumberFormat("de-DE")` → `1.234,56`. Currency trails with a no-break space: `{style:"currency",currency:"EUR"}` → `1.234,56 €`, never `€15`. Dates: `dateStyle:"long"` → `23. Juli 2026`; numeric is `DD.MM.YYYY`. A period decimal (`1.5`) reads as fifteen hundred — an error, not a nit.
- **DIN-5008 spacing.** A `&nbsp;` between value and unit or symbol and inside spaced abbreviations, so nothing wraps: `15&nbsp;€`, `20&nbsp;%` (German spaces before `%`), `z.&nbsp;B.`, `d.&nbsp;h.`
- **Re-check the hero.** The same headline runs 15–35% longer in German, so a `clamp()` tuned on English overflows or orphans a lone compound. Re-verify each German hero at 375px against the `ultraweb:typography` clamp scale — tighten the `min`, add a `&shy;`, or let `text-wrap: balance` resolve the rag.

## Copy discipline

Each locale is WRITTEN by `ultraweb:copywriting` in that market's voice — never machine-transliterated English. Idioms, formality register (du/Sie), and CTA verbs are per-market decisions. `gate-responsive` screenshots run on the longest locale, not on English.

**Leichte Sprache** is register, not translation — a third axis beside tone and locale, serving cognitive disabilities, non-native readers, and low-literacy users at once. Turn it on only for `de`-locale civic, foundation, government-adjacent, or regulated-consumer briefs (never a game studio or textiles shop absent a real audience); it is legally expected in German public contexts (BITV 2.0) and BFSG-relevant for regulated consumer services from June 2025. Ship it as a genuine `/de/leichte-sprache/<page>` route for the pages that matter — mission/about, contact or donation, the load-bearing legal/process pages (Impressum, AGB, Datenschutz, checkout) — plus a visible "Leichte Sprache" link, top-right and in the footer. Its rules are real craft: one statement per sentence, one idea per line, active voice and present tense, no Konjunktiv or Genitive, spelled-out abbreviations, long compounds split with a hyphen (`Kaffee-Bestellung`). `ultraweb:copywriting` writes it natively and the target group validates it — never machine-simplified from the standard copy, which produces confident nonsense.

## Anti-patterns

- `middleware.ts` for locale detection — deprecated in Next 16; negotiation lives in `proxy.ts`
- locale only in a cookie — unshareable URLs, uncrawlable variants
- IP-geolocation auto-redirect — a VPN user in Vienna is not a language decision; Accept-Language plus a visible switcher
- dictionary import inside a `"use client"` file — ships every string to the client (`server-only` correctly explodes)
- `t("key.name")` string-lookup helpers — object access `t.nav.work` is typo-proof and typed for free
- hardcoded UI strings left outside the dictionary — grep components for quoted prose after wiring
- `toLocaleDateString(` / `toLocaleString(` with no locale argument — hydration mismatch
- shipping a locale with untranslated fallback strings mixed in — cut the locale instead
- installing an i18n framework for a 2-locale brochure site
- straight `"…"`, `€15`, or a period decimal in a `de` locale — German is `„…"` and `1.234,56 €` via `Intl.NumberFormat("de-DE")`; `1.5` reads as a thousands separator
- `text-transform: uppercase` on German `ß` with no `SS`/`ẞ` decision — verify the glyph the font actually emits
- Leichte Sprache as a PDF, a footer afterthought, or machine-simplified from the standard copy — it is a real `/de/leichte-sprache/` page written natively, or it is not done

## Worked example — Casa Verde, EN/PT restaurant menu + reservations

Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.

## Composes with

Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.

