ADR-17: next-intl i18n Strategy
| Date | Author | Repos |
|---|---|---|
| 2025-12-06 | @KubrickCode | web |
Context
The Internationalization Requirement
SpecVital targets both Korean and English-speaking developer communities. A proper i18n solution was needed to:
- Support Korean and English languages initially
- Provide URL-based routing for SEO benefits (
/ko/...,/en/...) - Integrate seamlessly with Next.js App Router and Server Components
- Enable browser language detection for first-time visitors
- Maintain type safety for translation keys
App Router Challenges
Next.js 13+ App Router removed the built-in i18n configuration that Pages Router provided. This created new requirements:
- Manual locale routing setup via
[locale]dynamic segments - Server Component compatibility (no
useEffector client-side detection) - Proper message loading without shipping all translations to the client
- Hydration safety for time-sensitive content (relative dates)
Decision
Adopt next-intl as the internationalization library for Next.js frontend.
Core implementation:
- URL-Based Routing:
/[locale]/prefix for all routes - Server-First:
getTranslations()for Server Components - ICU Message Format: Pluralization and interpolation support
- Browser Detection: Automatic redirect based on
Accept-Language
Options Considered
Option A: next-intl (Selected)
How It Works:
- Purpose-built for Next.js App Router
- Native Server Component support via
getTranslations() - Client Component hooks via
useTranslations() - Middleware handles locale detection and routing
Pros:
- App Router Native: Designed specifically for RSC and App Router
- Type Safety: TypeScript autocompletion for translation keys
- Bundle Optimization: Only loads messages for current locale on server
- Simple API: Identical hook API for both Server and Client Components
- Active Maintenance: Regular updates following Next.js releases
Cons:
- Smaller ecosystem compared to i18next
- Static rendering requires explicit
setRequestLocale()calls - Less documentation compared to react-i18next
Option B: react-i18next + next-i18next
How It Works:
- Mature i18next ecosystem with React bindings
- Requires additional setup for App Router compatibility
- Plugin-based architecture for features
Pros:
- Large ecosystem and community
- Extensive feature set (namespaces, backends, plugins)
- Battle-tested in production at scale
Cons:
- App Router Friction: Originally designed for Pages Router
- Complex configuration for Server Components
- Larger bundle size with full i18next core
- Requires workarounds for RSC compatibility
Option C: next-translate
How It Works:
- File-based translations with automatic code-splitting
- Simpler feature set than i18next
Evaluation:
- Limited App Router support at decision time
- Fewer updates compared to next-intl
- Missing some RSC-specific optimizations
- Rejected: Insufficient App Router integration
Option D: Built-in Next.js + Custom Solution
How It Works:
- Manual implementation using Next.js middleware
- Custom translation loading and hooks
Evaluation:
- Maximum flexibility but high maintenance cost
- Must implement pluralization, interpolation manually
- Risk of subtle hydration mismatches
- Rejected: Reinventing well-solved problems
Implementation Details
File Structure
src/frontend/
├── i18n/
│ ├── config.ts # Locale definitions
│ ├── navigation.ts # Localized Link, useRouter
│ ├── request.ts # Server-side message loading
│ └── routing.ts # Routing configuration
├── messages/
│ ├── en.json # English translations
│ └── ko.json # Korean translations
├── middleware.ts # Locale detection, redirects
└── app/[locale]/ # Locale-prefixed routesLocale Configuration
Two locales supported with English as default:
en(default): Englishko: Korean (한국어)
Middleware Behavior
- Check URL for locale prefix
- If missing, detect from
Accept-Languageheader - Redirect to appropriate locale path
- Set response cookies for subsequent requests
Translation Usage Patterns
Server Components:
const t = await getTranslations("namespace");
return <h1>{t("key")}</h1>;Client Components:
const t = useTranslations("namespace");
return <button>{t("action")}</button>;ICU Pluralization:
{
"tests": "{count, plural, =0 {No tests} =1 {1 test} other {# tests}}"
}Hydration Safety
For time-sensitive content like relative dates, use useNow() hook:
const now = useNow({ updateInterval: 60000 });
const formatted = formatRelativeTime(date, now);This prevents hydration mismatches between server and client render times.
Consequences
Positive
Developer Experience:
- TypeScript autocompletion for translation keys
- Consistent API across Server and Client Components
- Clear separation of translation files by locale
Performance:
- Server-side message resolution (no client bundle bloat)
- Per-route translation loading
- Lazy loading for client components if needed
SEO Benefits:
- Locale-prefixed URLs for search engines
- Proper
langattribute on<html> hreflangalternates in metadata
User Experience:
- Automatic language detection on first visit
- Shareable localized URLs
- Language switcher in header
Negative
Static Rendering Limitation:
- Currently requires
setRequestLocale()for static rendering - Addressed in each layout and page component
- Mitigation: Future next-intl versions aim to remove this requirement
Learning Curve:
- Team must learn ICU message format for pluralization
- Different APIs for async vs sync contexts
- Mitigation: Documented patterns in CLAUDE.md
Message Management:
- Manual synchronization between language files
- Risk of missing translations in one locale
- Mitigation: CI checks for missing keys (future enhancement)
