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.mdnames 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
- Locale registry — one shared module, no framework:
// lib/i18n.ts
export const locales = ["en", "de"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
- Segment: move all pages under
app/[locale]/; its layout is the root layout and owns<html>. Params are Promises in Next 16:
// 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>;
}
- Dictionaries — server-only, lazy, typed against the default locale:
// 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:
// 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;
- Pages consume:
const { locale } = await params; const t = await getDictionary(locale);— passt.nav,t.herodown as props. Leaf client components receive strings as props and never import dictionaries;server-onlythrows at build time if they try. - Negotiation in proxy.ts —
middleware.tsis deprecated in Next 16:
// 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.
- hreflang via metadata alternates (requires the
metadataBasethatultraweb:seosets). Every locale variant lists ALL variants including itself, plusx-defaultpointing at the default locale:
alternates: {
canonical: `/${locale}/work`,
languages: { en: "/en/work", de: "/de/work", "x-default": "/en/work" },
},
- Switcher: header links to the SAME route with the segment swapped —
ultraweb:navigationplaces it as a designed element. Never a dropdown that reloads to the homepage. - Formatting —
Intlis the library; pass the locale explicitly, always:
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.
Rechtsschutzversicherungrags brutally and overflows heroes unbroken.hyphens: autoon prose — it relies on thelangthe[locale]layout already sets. Keep it off display type (auto-hyphenated heroes read cheap); break those with a soft hyphen­at a chosen seam ortext-wrap: balance. - Quotation glyphs. German quotes are
„…"(open low, close high —„Kaffee aus Leidenschaft"), not"…". The editorial register uses guillemets»…«(inward-pointing; Swissde-CHuses«…»). Copywriting writes the real glyphs into the dictionary; set the property for any<q>::lang(de){ quotes:"„" "“" "‚" "‘" }. - ß, ẞ, and de-CH.
de-DE/de-ATkeepß; Switzerland (de-CH) never uses it — alwaysss. In an uppercase headline decide the cap deliberately:text-transform: uppercaseemitsSS(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 isDD.MM.YYYY. A period decimal (1.5) reads as fifteen hundred — an error, not a nit. - DIN-5008 spacing. A
between value and unit or symbol and inside spaced abbreviations, so nothing wraps:15 €,20 %(German spaces before%),z. B.,d. 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 theultraweb:typographyclamp scale — tighten themin, add a­, or lettext-wrap: balanceresolve 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.tsfor locale detection — deprecated in Next 16; negotiation lives inproxy.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-onlycorrectly explodes) t("key.name")string-lookup helpers — object accesst.nav.workis 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 adelocale — German is„…"and1.234,56 €viaIntl.NumberFormat("de-DE");1.5reads as a thousands separator text-transform: uppercaseon Germanßwith noSS/ẞ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.