i18n: next-intl with path-based routing across 5 locales
Migrated the entire frontend to `next-intl` with path-based routing (`/en`, `/ko`, `/ja`, `/zh`, `/es`), default English. All 17 user-facing files were keyed; `<Link>` and `useRouter` imports across the app moved to `@/i18n/navigation` wrappers. ja/zh/es fall back to English copy for now (translation is a non-engineering task).
The decision to add UI internationalization was a product call, not a tech one: the user-facing default switched to English (the actual learner audience), with Korean as a hand-translated fallback. ja/zh/es ship as English fallback until I (or contributors) translate them — the routing is ready.
Stack: next-intl with path-based routing. URLs become /en/library, /ko/library, etc. The default locale (en) is not implicit — every URL carries a prefix. This is the variant that survives prerendering and SEO without surprises.
The migration was almost entirely mechanical:
- Routing —
i18n/routing.ts(locale list + default),i18n/request.ts(load messages by locale),i18n/navigation.ts(typed<Link>anduseRouterwrappers),middleware.ts(createMiddleware(routing)),next.config.tsplugin. - App directory move —
app/*→app/[locale]/*. Rootlayout.tsxdeleted; the locale-aware layout lives atapp/[locale]/layout.tsxand is the canonical root. - Imports — every
from "next/link"and theuseRouter/usePathnamefrom"next/navigation"had to switch to"@/i18n/navigation". Did it withsed -i ''across 14 files. The trap was thatnext/linkis a default export whilenext-intl/navigation'sLinkis named — a secondsedto fiximport Link from→import { Link } from. - Keys — pulled every Korean string into
messages/{en,ko,...}.jsonwith structured namespaces (common,home,nav,auth,library,import,review,settings,discover,video,clipCreate,clipPlayer,note,recording,transcript,streak,shortcuts,globalError,notFound,analysis,blind,audioMode,locale). - Verification — added
e2e/tests/i18n.spec.tscovering 5 locale routes + locale selector flips/en→/koand back.
Lesson: centralize navigation imports from day one if you might ever localize. Replacing them later is a one-shot sed either way, but the named/default import disagreement between next/link and next-intl/navigation cost an extra round.
The locale selector lives in the app header and writes the selected locale to localStorage via the wrapped router; cookie-based detection happens via middleware on first request.
Commit: ba90e00 — [feat] i18n + 직독직해 + Output quizzes + Decks + Playlist + project log