drop-localization-spec
Drop Localization (i18n) — Architect Specification
Project: Drop Fintech Payment App Component: Internationalization (i18n) — NO/EN/SV Author: John (Architect Agent) Date: 2026-02-17 Status: Draft for Review
Executive Summary
This specification defines the complete internationalization (i18n) architecture for Drop, enabling support for Norwegian Bokmål (primary/default), English, and Swedish. The system will extract all hardcoded Norwegian strings from the current codebase, implement a type-safe translation framework using next-intl (the de facto standard for Next.js 16 App Router), and establish a sustainable translation workflow.
Key Decisions:
- Framework: next-intl (App Router native, type-safe, 0 client-side JS for translations)
- Primary Language: Norwegian Bokmål (
nb-NO) — default, required by law for legal documents - Additional Languages: English (
en), Swedish (sv) - Routing Strategy: Subdirectory-based (
/no/,/en/,/sv/) with automatic language detection - Migration Approach: Phased rollout (Phase 1: Framework + UI, Phase 2: Email templates, Phase 3: Legal docs)
1. Framework Selection
1.1 Evaluation Criteria
| Library | Next.js 16 App Router | Type Safety | Bundle Size | Maintenance | Verdict |
|---|---|---|---|---|---|
| next-intl | ✅ Native support | ✅ Full TS support | ~5KB gzipped | ✅ Active (2024-2026) | RECOMMENDED |
| react-i18next | ❌ Client-side only | ⚠️ Partial (manual) | ~18KB gzipped | ✅ Active | Not suitable |
| next-international | ✅ App Router support | ✅ Full TS support | ~8KB gzipped | ⚠️ Smaller community | Alternative |
| react-intl | ❌ Requires workarounds | ⚠️ Partial | ~19KB gzipped | ✅ Active | Not suitable |
Source: next-intl vs react-i18next comparison, i18n library comparison
1.2 Why next-intl?
- App Router Native: Built specifically for Next.js 16 App Router with server component support (next-intl App Router guide)
- Zero Client-Side JS: Translations preloaded server-side, sent as props to server components
- Type Safety: Auto-completion for message keys, compile-time checks for missing translations
- Routing Built-In:
[locale]dynamic segment integration out of the box (routing setup) - Format Functions: ICU message syntax, date/time formatting, number formatting per locale
- Production-Ready: Used by Node.js official website, Sitecore SDK, Vercel templates
Reference: Official next-intl documentation, Next.js 16 i18n guide
2. Language Support Matrix
| Locale Code | Language | Variant | Priority | Default | Legal Required | Notes |
|---|---|---|---|---|---|---|
nb-NO |
Norwegian | Bokmål | P0 | ✅ Yes | ✅ Yes | 85-90% of Norwegian population uses Bokmål (source) |
en |
English | Generic | P1 | ❌ No | ❌ No | International users, diaspora secondary language |
sv |
Swedish | Generic | P2 | ❌ No | ❌ No | Scandinavia expansion (future) |
Nynorsk Exclusion Rationale: Drop targets urban areas and general Norwegian population. Bokmål is the standard for fintech/banking in Norway. Nynorsk is primarily rural/western Norway (10% usage) and would require separate legal review. (source)
Future Expansion: Arabic, Somali, Polish (after MVP — diaspora remittance corridors)
3. Current Codebase Analysis
3.1 Hardcoded Norwegian Strings Identified
Total Files with Norwegian Text: 36 files (from Grep scan)
Categories:
| Category | Example Strings | File Count | Complexity |
|---|---|---|---|
| UI Labels | "Hjem", "Kontoer", "Historikk", "Profil", "Send", "Skann" | ~15 | Low |
| Form Validation | "E-post og passord er påkrevd", "Ugyldig e-postadresse" | ~8 | Low |
| Dashboard Content | "God morgen", "God ettermiddag", "God kveld", "Brukskonto" | ~5 | Medium |
| Email Templates | "Velkommen til Drop", "Verifiser konto", "Send penger internasjonalt" | 3 | High |
| Legal Documents | "Vilkår for bruk", "Om tjenesten", "Krav til brukere" | 3 | High |
| API Error Messages | "Too many requests", "Invalid credentials", "Email and password required" | ~10 | Medium |
| Notifications | "Vipps-innlogging kommer snart!", "Oppdatert via BankID" | ~5 | Low |
Files Requiring Extraction (High Priority):
-
UI Components:
src/components/bottom-nav.tsx— Navigation labels (Hjem, Kontoer, Historikk, Profil)src/app/dashboard/page.tsx— Greetings, account labelssrc/app/login/page.tsx— Form labels, validation errors, button textsrc/app/register/page.tsx— Registration flow textsrc/app/send/page.tsx— Remittance formsrc/app/scan/page.tsx— QR payment UI
-
API Routes:
src/app/api/auth/login/route.ts— "Invalid credentials", "Email and password required"src/app/api/transactions/*/route.ts— Transaction error messages
-
Email Templates:
src/email-templates/welcome.html— Full Norwegian welcome emailsrc/email-templates/transaction-receipt.html— Receipt emailsrc/email-templates/password-reset.html— Password reset email
-
Legal Pages:
src/app/terms/page.tsx— Full terms of service (Norwegian)src/app/privacy/page.tsx— Privacy policysrc/app/fees/page.tsx— Fee schedule
3.2 Special Cases
Currency Formatting:
- Current:
user.totalBalance.toLocaleString("nb-NO", { minimumFractionDigits: 0 })(hardcoded locale) - New: Use next-intl's
useFormatter()hook for locale-aware formatting
Date Formatting:
- Current: No date formatting found (transactions show ISO strings in tests)
- New: Implement
formatDateTime()from next-intl
Number Formatting:
- Current: Hardcoded "kr" currency symbol, hardcoded "NOK" suffix
- New:
formatNumber(value, {style: 'currency', currency: 'NOK'})
4. Translation File Structure
4.1 Directory Layout
src/drop-app/
├── messages/ # Translation files (JSON)
│ ├── nb-NO.json # Norwegian Bokmål (default)
│ ├── en.json # English
│ ├── sv.json # Swedish
│ └── README.md # Translation guidelines
├── i18n/ # i18n configuration
│ ├── config.ts # Locale definitions, default locale
│ └── request.ts # next-intl request config (App Router)
├── middleware.ts # Locale detection middleware
└── app/
└── [locale]/ # Locale-based routing
├── layout.tsx # Root layout with NextIntlClientProvider
├── page.tsx # Redirects to /dashboard
├── dashboard/
├── login/
└── ... # All existing routes nested under [locale]
4.2 Namespace Strategy
Single JSON file per locale (initial approach): For Drop MVP, all translations in one file per locale. Future: split into namespaces as app grows.
File: messages/nb-NO.json
{
"common": {
"app_name": "Drop",
"tagline": "Enklere betalinger. Lavere gebyrer.",
"loading": "Laster...",
"error": "Noe gikk galt",
"retry": "Prøv igjen",
"cancel": "Avbryt",
"confirm": "Bekreft",
"save": "Lagre"
},
"nav": {
"home": "Hjem",
"accounts": "Kontoer",
"scan": "Skann",
"transactions": "Historikk",
"profile": "Profil"
},
"login": {
"title": "Logg inn",
"email_label": "E-post",
"password_label": "Passord",
"submit": "Logg inn",
"error_required": "E-post og passord er påkrevd",
"error_invalid_email": "Ugyldig e-postadresse",
"error_invalid_credentials": "Feil e-post eller passord",
"bankid_button": "BankID",
"vipps_button": "Vipps",
"vipps_coming_soon": "Vipps-innlogging kommer snart!"
},
"dashboard": {
"greeting_morning": "God morgen",
"greeting_afternoon": "God ettermiddag",
"greeting_evening": "God kveld",
"account_label": "{bankName} Brukskonto",
"account_updated": "Oppdatert via BankID",
"action_send": "Send",
"action_scan": "Skann",
"action_accounts": "Kontoer",
"action_history": "Historikk"
},
"validation": {
"required": "Dette feltet er påkrevd",
"invalid_email": "Ugyldig e-postadresse",
"invalid_phone": "Ugyldig telefonnummer",
"min_age": "Du må være minst 18 år",
"invalid_amount": "Ugyldig beløp"
},
"email": {
"welcome_subject": "Velkommen til Drop!",
"welcome_body": "Vi er glade for å ha deg med. Med Drop kan du sende penger internasjonalt og betale i butikk – enklere og billigere enn noen gang.",
"verify_cta": "Verifiser konto",
"support_email": "support@getdrop.no"
},
"legal": {
"terms_title": "Vilkår for bruk",
"privacy_title": "Personvernerklæring",
"fees_title": "Gebyrer og priser"
},
"errors": {
"rate_limited": "For mange forsøk. Prøv igjen senere.",
"unauthorized": "Du må logge inn for å fortsette",
"not_found": "Siden finnes ikke",
"server_error": "Noe gikk galt. Prøv igjen senere."
}
}
File: messages/en.json
{
"common": {
"app_name": "Drop",
"tagline": "Easier payments. Lower fees.",
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Try again",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save"
},
"nav": {
"home": "Home",
"accounts": "Accounts",
"scan": "Scan",
"transactions": "History",
"profile": "Profile"
},
"login": {
"title": "Log in",
"email_label": "Email",
"password_label": "Password",
"submit": "Log in",
"error_required": "Email and password required",
"error_invalid_email": "Invalid email address",
"error_invalid_credentials": "Invalid email or password",
"bankid_button": "BankID",
"vipps_button": "Vipps",
"vipps_coming_soon": "Vipps login coming soon!"
}
// ... rest of translations
}
5. Key Translation Categories
5.1 UI Text (Priority 1)
Scope: All visible text in React components, buttons, labels, placeholders, tooltips.
Extraction Method:
- Search for JSX text content:
<span>Text</span>→<span>{t('key')}</span> - Search for string literals in className, title, aria-label
- Replace hardcoded strings with
t()calls
Tools: Manual extraction + ESLint rule to prevent future hardcoded strings
Example (Before):
<span className="text-xs text-[#1E293B]">Hjem</span>
Example (After):
<span className="text-xs text-[#1E293B]">{t('nav.home')}</span>
5.2 Error Messages (Priority 1)
Scope: API route error responses, form validation errors, toast notifications.
Current State: Mix of English and Norwegian error messages in API routes.
Strategy:
- Backend API routes return error keys (not localized strings)
- Frontend translates error keys using next-intl
- Fallback to English for unknown keys
Example (API Route — Before):
return jsonError("unauthorized", "Invalid credentials", 401);
Example (API Route — After):
return jsonError("unauthorized", "errors.invalid_credentials", 401);
// Note: Second param is translation KEY, not message
Example (Frontend):
const errorMessage = t(`errors.${errorKey}`);
toast.error(errorMessage);
5.3 Email Templates (Priority 2)
Scope: 3 email templates (welcome.html, transaction-receipt.html, password-reset.html)
Challenge: HTML email templates don't support React components.
Solution:
- Create template functions that accept locale parameter
- Store email translations in same
messages/*.jsonfiles underemail.*namespace - Server-side template rendering with locale-specific strings
Current: src/email-templates/welcome.html (static Norwegian HTML)
New: src/lib/email-templates.ts
import { getTranslations } from 'next-intl/server';
export async function renderWelcomeEmail(locale: string, data: {verifyUrl: string}) {
const t = await getTranslations({locale, namespace: 'email'});
return `
<!DOCTYPE html>
<html lang="${locale}">
<head><title>${t('welcome_subject')}</title></head>
<body>
<h1>${t('welcome_subject')}</h1>
<p>${t('welcome_body')}</p>
<a href="${data.verifyUrl}">${t('verify_cta')}</a>
</body>
</html>
`;
}
Email Sending:
const html = await renderWelcomeEmail(user.preferredLanguage || 'nb-NO', {verifyUrl});
await sendEmail({to: user.email, subject: t('email.welcome_subject'), html});
5.4 Legal Documents (Priority 3 — Manual Translation Required)
Scope: Terms of Service, Privacy Policy, Fee Schedule
Legal Requirement: Norwegian version MUST exist and be primary (PSD2 Norway implementation). English/Swedish versions are optional.
Strategy:
- Phase 1 (MVP): Norwegian-only legal docs (current state)
- Phase 2 (Post-MVP): Professional translation of legal docs to English (external translator)
- Store legal content as Markdown files in
messages/legal/[locale]/directory - Render Markdown server-side using
@next/mdxor similar
Structure:
messages/
└── legal/
├── nb-NO/
│ ├── terms.md
│ ├── privacy.md
│ └── fees.md
├── en/
│ ├── terms.md
│ ├── privacy.md
│ └── fees.md
└── sv/
└── ...
Legal Page Component:
import fs from 'fs/promises';
import { compileMDX } from 'next-mdx-remote/rsc';
export default async function TermsPage({params}: {params: {locale: string}}) {
const locale = params.locale || 'nb-NO';
const source = await fs.readFile(`messages/legal/${locale}/terms.md`, 'utf8');
const {content} = await compileMDX({source});
return <div className="prose">{content}</div>;
}
Fallback: If English/Swedish legal docs don't exist, show Norwegian version with disclaimer: "Legal documents available in Norwegian only."
6. Formatting Standards
6.1 Currency Formatting
Norwegian Locale (nb-NO):
- Format:
1 234,56 kr(space as thousand separator, comma as decimal, suffix "kr") - Alternative (banking):
NOK 1 234,56(ISO code prefix) - Drop Standard: Use
1 234 kr(no decimals for whole amounts) to match UX mockups
English Locale (en):
- Format:
NOK 1,234.56(ISO code, comma thousand separator, period decimal)
Swedish Locale (sv):
- Format:
1 234,56 kr(same as Norwegian)
Implementation (next-intl):
import {useFormatter} from 'next-intl';
const format = useFormatter();
const formatted = format.number(1234.56, {
style: 'currency',
currency: 'NOK',
minimumFractionDigits: 0, // Drop shows whole kroner
maximumFractionDigits: 0
});
// nb-NO: "1 235 kr"
// en: "NOK 1,235"
Reference: Microsoft Currency Formatting Guide, Norwegian Bokmål locale formatting
6.2 Date & Time Formatting
Norwegian (nb-NO):
- Short date:
17.02.2026(DD.MM.YYYY) - Long date:
17. februar 2026 - Time:
14:30(24-hour clock)
English (en):
- Short date:
02/17/2026(MM/DD/YYYY) or17/02/2026(international) - Long date:
February 17, 2026 - Time:
2:30 PM(12-hour clock)
Implementation:
const format = useFormatter();
const date = new Date('2026-02-17T14:30:00Z');
format.dateTime(date, {dateStyle: 'short'});
// nb-NO: "17.02.2026"
// en: "2/17/2026"
format.dateTime(date, {dateStyle: 'long', timeStyle: 'short'});
// nb-NO: "17. februar 2026 kl. 14:30"
// en: "February 17, 2026 at 2:30 PM"
6.3 Number Formatting
Norwegian (nb-NO):
- Decimal separator:
,(comma) - Thousand separator:
(non-breaking space) - Example:
1 234 567,89
English (en):
- Decimal separator:
.(period) - Thousand separator:
,(comma) - Example:
1,234,567.89
Implementation:
format.number(1234567.89, {maximumFractionDigits: 2});
// nb-NO: "1 234 567,89"
// en: "1,234,567.89"
7. Translation Workflow
7.1 Roles & Responsibilities
| Role | Responsibility | Tools |
|---|---|---|
| Developer | Extract strings to translation files, add translation keys to code | VSCode, ESLint |
| Content Lead (Alem) | Review Norwegian translations for accuracy, approve final content | GitHub PR review |
| External Translator | Translate nb-NO.json → en.json, sv.json (Phase 2) |
JSON editor, CAT tool |
| Legal Team | Translate legal documents (terms, privacy, fees) | Markdown editor |
| QA | Test all locales, verify formatting, check for missing translations | Browser, Playwright |
7.2 Translation Process
Phase 1: Initial Extraction (Developer)
- Create
messages/nb-NO.jsonfrom existing Norwegian strings - Structure translation keys by namespace (
common,nav,login, etc.) - Replace hardcoded strings in components with
t('key')calls - Run build to verify no missing keys (TypeScript will catch errors)
- Test Norwegian locale thoroughly (should match current behavior exactly)
Phase 2: English Translation (External Translator)
- Export
messages/nb-NO.json - Translator creates
messages/en.json(JSON structure preserved, values translated) - Developer imports
en.json, runs build - QA tests English locale
Phase 3: Swedish Translation (Future)
Same process as Phase 2.
7.3 Quality Assurance
Pre-Deployment Checklist:
- All UI screens tested in all 3 locales (nb-NO, en, sv)
- Currency formatting correct for each locale
- Date formatting correct for each locale
- No missing translation keys (TypeScript build passes)
- Email templates render correctly in all locales
- Legal documents exist for required locales (nb-NO mandatory)
- Language switcher works (Profile → Language)
- URL routing works (
/no/dashboard,/en/dashboard,/sv/dashboard) - Fallback to Norwegian works if user selects unsupported locale
- Browser language detection works (first visit)
Automated Tests:
- Playwright E2E: Test key user flows in all 3 locales
- Unit Tests: Test
formatCurrency(),formatDate()with mock locales - Snapshot Tests: Compare rendered output in different locales
8. Database Content Localization
8.1 User-Generated Content
Scope: User names, recipient names, custom notes on transactions.
Strategy: NOT translated. User-generated content stored as-is in database.
8.2 System-Generated Content
Scope: Transaction status messages, notification content, system emails.
Strategy:
- Store translation keys in database, not localized text
- Render localized text at display time based on user's preferred language
Example:
Database:
INSERT INTO notifications (user_id, message_key, data_json) VALUES
(123, 'notification.transfer_completed', '{"amount": 500, "recipient": "John Doe"}');
Display (React component):
const t = useTranslations('notification');
const notification = await getNotification(id);
const message = t(notification.message_key, JSON.parse(notification.data_json));
// nb-NO: "Overføring på 500 kr til John Doe er fullført"
// en: "Transfer of NOK 500 to John Doe completed"
Translation File:
{
"notification": {
"transfer_completed": "{amount, number, ::currency/NOK} til {recipient} er fullført"
}
}
8.3 Static Reference Data
Scope: Country names, bank names, currency names.
Strategy:
- Store in separate
reference-datanamespace - Pre-translate all reference data (drop countries, supported currencies)
Example:
{
"countries": {
"NO": "Norge",
"SE": "Sverige",
"PL": "Polen"
},
"currencies": {
"NOK": "Norske kroner",
"SEK": "Svenske kroner",
"EUR": "Euro"
}
}
9. URL & Routing Strategy
9.1 Subdirectory-Based Routing (Recommended)
Structure: /[locale]/[route]
Examples:
/no/dashboard— Norwegian/en/dashboard— English/sv/dashboard— Swedish/— Root, redirects to/no/(default)
Pros:
- SEO-friendly (separate URL per language)
- Shareable links preserve language
- Server-side rendering compatible
- No cookie/session required for language persistence
Cons:
- URL changes when switching language (acceptable trade-off)
Implementation:
File: src/middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['nb-NO', 'en', 'sv'],
defaultLocale: 'nb-NO',
localePrefix: 'as-needed' // /nb-NO/dashboard → /dashboard (default), /en/dashboard (explicit)
});
export const config = {
matcher: ['/', '/(nb-NO|en|sv)/:path*']
};
File: src/app/[locale]/layout.tsx
import {NextIntlClientProvider} from 'next-intl';
import {notFound} from 'next/navigation';
export default async function LocaleLayout({
children,
params: {locale}
}: {
children: React.ReactNode;
params: {locale: string};
}) {
let messages;
try {
messages = (await import(`@/messages/${locale}.json`)).default;
} catch (error) {
notFound();
}
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
9.2 Language Detection
Priority:
- URL locale (
/en/dashboard→en) - User profile
preferred_language(if logged in) - Browser
Accept-Languageheader (first visit) - Fallback to
nb-NO(default)
Implementation:
File: src/i18n/config.ts
import {getRequestConfig} from 'next-intl/server';
import {headers} from 'next/headers';
export default getRequestConfig(async ({locale}) => {
// Locale from URL (/en/dashboard) or middleware detection
return {
messages: (await import(`../messages/${locale}.json`)).default
};
});
User Preference Storage:
ALTER TABLE users ADD COLUMN preferred_language TEXT DEFAULT 'nb-NO';
API Route to Update Preference:
// POST /api/settings/language
await db.run('UPDATE users SET preferred_language = ? WHERE id = ?', [locale, userId]);
9.3 Language Switcher UI
Location: Profile → Language Settings (/[locale]/profile/language)
UI:
import {useRouter, usePathname} from 'next/navigation';
import {useLocale} from 'next-intl';
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const currentLocale = useLocale();
const switchLocale = (newLocale: string) => {
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<select value={currentLocale} onChange={(e) => switchLocale(e.target.value)}>
<option value="nb-NO">🇳🇴 Norsk</option>
<option value="en">🇬🇧 English</option>
<option value="sv">🇸🇪 Svenska</option>
</select>
);
}
10. Legal Requirements (PSD2 / Finanstilsynet)
10.1 Mandatory Norwegian Disclosure
Requirement: Payment service providers in Norway must comply with host state rules on disclosure obligations and customer protection (PSD2 Norway implementation).
Implication: Terms of Service, Privacy Policy, and Fee Schedule MUST be available in Norwegian.
Compliance:
- Norwegian legal documents are mandatory (P0)
- English/Swedish legal documents are optional (P2, improves UX for non-Norwegian speakers)
- If non-Norwegian user selects English, show English UI but link to Norwegian legal docs with disclaimer:
"Legal documents are provided in Norwegian only, as required by Norwegian law. For an unofficial translation, please use a translation service."
10.2 Consent & Agreement Language
Requirement: User must consent in a language they understand.
Strategy:
- During onboarding, detect user's language preference
- Display Terms of Service in user's language (if available)
- If not available, show Norwegian with disclaimer + user confirms they understand
- Log consent with
language_of_consentin database
Database:
ALTER TABLE users ADD COLUMN language_of_consent TEXT;
-- Example: 'nb-NO', 'en', 'sv'
10.3 Customer Support Language
Requirement: Not legally mandated, but best practice.
Strategy:
- Support email (
support@getdrop.no) responds in Norwegian (primary) and English (secondary) - Swedish support on-demand (Google Translate fallback initially)
11. Testing Strategy
11.1 Unit Tests
Framework: Vitest (already in use)
Test Files:
src/lib/i18n/formatters.test.ts— Currency, date, number formattingsrc/lib/i18n/translations.test.ts— Translation key coverage
Example:
import {formatCurrency} from '@/lib/formatters';
describe('formatCurrency', () => {
it('formats NOK in Norwegian locale', () => {
expect(formatCurrency(1234, 'nb-NO')).toBe('1 234 kr');
});
it('formats NOK in English locale', () => {
expect(formatCurrency(1234, 'en')).toBe('NOK 1,234');
});
});
11.2 Integration Tests
Framework: Playwright (already in use)
Test Scenarios:
- Language Switcher: Switch from Norwegian → English → Swedish, verify UI updates
- Currency Formatting: Check dashboard balance shows correct format per locale
- Date Formatting: Check transaction history dates render correctly
- Email Templates: Generate email in each locale, verify content
- Legal Pages: Load terms/privacy in each locale, verify fallback if missing
Example:
test('dashboard shows correct currency format for Norwegian locale', async ({page}) => {
await page.goto('/nb-NO/dashboard');
await expect(page.locator('text=/\\d+ kr/')).toBeVisible(); // "1 234 kr" format
});
test('dashboard shows correct currency format for English locale', async ({page}) => {
await page.goto('/en/dashboard');
await expect(page.locator('text=/NOK \\d+/')).toBeVisible(); // "NOK 1,234" format
});
11.3 Manual QA Checklist
Pre-Release Testing (All Locales):
| Test Case | nb-NO | en | sv | Notes |
|---|---|---|---|---|
| Login page renders correctly | ☐ | ☐ | ☐ | Check labels, buttons, errors |
| Dashboard shows greeting in correct language | ☐ | ☐ | ☐ | "God morgen" vs "Good morning" |
| Currency formatting matches locale | ☐ | ☐ | ☐ | "1 234 kr" vs "NOK 1,234" |
| Transaction history dates formatted correctly | ☐ | ☐ | ☐ | DD.MM.YYYY vs MM/DD/YYYY |
| Email templates render in correct language | ☐ | ☐ | ☐ | Send test emails |
| Legal pages load without errors | ☐ | ☐ | ☐ | Check fallback for missing translations |
| Language switcher changes UI language | ☐ | ☐ | ☐ | From profile settings |
| URL routing works for all locales | ☐ | ☐ | ☐ | /nb-NO/, /en/, /sv/ |
12. Migration Plan (Phased Rollout)
Phase 1: Framework + Core UI (Week 1-2)
Goal: Replace all hardcoded UI strings with next-intl translations. No new languages yet (Norwegian-only, but structured for future).
Tasks:
- Install next-intl:
npm install next-intl - Create translation files:
messages/nb-NO.json(copy all existing Norwegian strings)- Extract strings from:
- Navigation (
bottom-nav.tsx) - Login (
login/page.tsx) - Dashboard (
dashboard/page.tsx) - Profile (
profile/page.tsx) - All form validation errors
- Navigation (
- Setup routing:
- Create
app/[locale]/directory - Move all existing routes under
[locale]/ - Add middleware for locale detection
- Create
- Update components:
- Replace
"Hjem"witht('nav.home') - Replace
toLocaleString("nb-NO")withformat.number()
- Replace
- Test: Verify app works exactly as before (Norwegian-only, but via next-intl)
Deliverables:
messages/nb-NO.json(complete)- All UI components use
useTranslations()hook - Zero hardcoded Norwegian strings in TSX files
- Build passes, Playwright tests pass
Success Criteria: App looks/behaves identical to current version, but all strings come from translation file.
Phase 2: English + Email Templates (Week 3)
Goal: Add English locale, translate email templates.
Tasks:
- Translate UI to English:
- Create
messages/en.json(external translator or Alem review) - Add "English" option to language switcher
- Create
- Refactor email templates:
- Convert
email-templates/*.htmltolib/email-templates.tsfunctions - Add
email.*namespace to translation files - Update email sending logic to accept locale parameter
- Convert
- Test emails:
- Send test emails in Norwegian and English
- Verify formatting (date/currency in emails)
Deliverables:
messages/en.json(complete)- Email templates support both locales
- Language switcher functional
Success Criteria: Users can switch to English, all UI + emails render correctly in English.
Phase 3: Swedish + Legal Docs (Week 4)
Goal: Add Swedish locale, translate legal documents (or defer if not ready).
Tasks:
- Translate UI to Swedish:
- Create
messages/sv.json - Add "Svenska" to language switcher
- Create
- Legal document strategy:
- Option A: Professional translation of terms/privacy/fees to English (defer Swedish)
- Option B: Keep Norwegian-only legal docs, show disclaimer for EN/SV users
- Production deployment:
- Deploy with all 3 locales
- Monitor for missing translations (Sentry alerts)
Deliverables:
messages/sv.json(complete)- Legal document translation strategy finalized
- Production-ready i18n system
Success Criteria: All 3 locales functional, legal compliance maintained.
Phase 4: Polish & Optimization (Week 5+)
Goal: Refine translations, add missing edge cases, optimize bundle size.
Tasks:
- Translation review:
- Native speakers review Norwegian/Swedish translations
- Collect user feedback on clarity
- Namespace splitting:
- Split large
nb-NO.jsonintocommon.json,auth.json,dashboard.json, etc. - Lazy-load translation namespaces for faster initial load
- Split large
- ESLint rule:
- Add ESLint rule to prevent future hardcoded strings:
// .eslintrc.js rules: { 'no-restricted-syntax': [ 'error', { selector: 'JSXText[value=/[a-zæøåA-ZÆØÅ]{3,}/]', message: 'Hardcoded text not allowed. Use useTranslations() hook.' } ] }
- Add ESLint rule to prevent future hardcoded strings:
- Performance audit:
- Measure bundle size impact of next-intl
- Verify server-side rendering works (translations in initial HTML)
Success Criteria: i18n system stable, maintainable, performant.
13. Implementation Estimates
| Phase | Tasks | Effort | Owner |
|---|---|---|---|
| Phase 1: Framework + Core UI | Install next-intl, extract strings, setup routing, update components | 2-3 days | Builder agent |
| Phase 2: English + Email Templates | Translate UI, refactor email templates, test | 1-2 days | Builder + Translator |
| Phase 3: Swedish + Legal Docs | Translate UI, legal doc strategy, deploy | 1-2 days | Builder + Legal |
| Phase 4: Polish & Optimization | Review, namespace split, ESLint rule, audit | 1-2 days | Builder + Validator |
| Total | Full i18n implementation | 5-9 days | Team |
Assumptions:
- Developer familiar with next-intl (1 day learning curve included)
- External translator available for English (1 day turnaround)
- Legal document translation deferred to post-MVP (Phase 3 can proceed with Norwegian-only legal docs)
14. Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Missing translations break production | High | Medium | TypeScript type-checking catches missing keys at build time. Add runtime fallback to Norwegian. |
| Translation quality poor (machine translation) | Medium | High | Use professional translator for English. Native speaker review for Swedish. |
| Legal documents not compliant in English | High | Low | Keep Norwegian legal docs as source of truth. English is "best effort" unofficial translation. |
| Performance regression (bundle size) | Low | Low | next-intl adds ~5KB gzipped. Negligible for Drop's use case. |
| URL routing breaks SEO | Medium | Low | Subdirectory routing (/en/) is SEO-friendly. Submit all locales to Google Search Console. |
| User confusion with language switcher | Low | Medium | Add clear UI labels, remember user preference in profile. |
| Email templates render incorrectly in some locales | Medium | Medium | Test emails in all locales before production. Use email testing tool (Litmus, Email on Acid). |
15. Success Metrics
Post-Deployment (30 days):
| Metric | Target | Measurement |
|---|---|---|
| Locale adoption (English) | >10% of users | Analytics: % of users with /en/ routes |
| Translation coverage | 100% of UI strings | Automated script: check all keys exist in all locales |
| Zero missing translation errors | 0 Sentry errors | Sentry: no TranslationKeyNotFound errors |
| Email deliverability (all locales) | >95% delivery rate | Email service provider metrics |
| Legal compliance | 100% Norwegian terms visible | Manual audit: all users see Norwegian legal docs |
16. Future Enhancements (Post-MVP)
- Right-to-Left (RTL) Support: Arabic, Somali for diaspora remittance corridors
- Translation Management Platform: Use Locize or Crowdin for professional translation workflow
- A/B Testing: Test Norwegian vs English CTAs for conversion optimization
- Voice of Customer: Collect user feedback on translation quality, iterate
- Automatic Language Detection: Use IP geolocation to suggest locale (Norway → Norwegian, USA → English)
17. References
Documentation
- next-intl Official Docs
- next-intl App Router Setup
- Next.js 16 i18n Tutorial
- Norwegian Bokmål Locale Formatting
Legal & Compliance
Localization Best Practices
- Norwegian Bokmål vs Nynorsk Localization
- Currency Formatting for Localization
- Microsoft Currency Formatting Guide
Library Comparisons
18. Appendix: Translation File Examples
A. Complete nb-NO.json (Sample)
See Section 4.2 for full structure.
B. ESLint Rule for Hardcoded Strings
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'JSXText[value=/[a-zæøåA-ZÆØÅ]{3,}/]',
message: 'Hardcoded text in JSX not allowed. Use useTranslations() hook from next-intl.'
}
]
}
};
C. next-intl Configuration Files
File: src/i18n/config.ts
export const locales = ['nb-NO', 'en', 'sv'] as const;
export type Locale = typeof locales[number];
export const defaultLocale: Locale = 'nb-NO';
File: src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server';
export default getRequestConfig(async ({locale}) => {
return {
messages: (await import(`../messages/${locale}.json`)).default
};
});
End of Specification
Next Steps:
- Review this spec with Alem for approval
- Create MC task for Phase 1 implementation
- Assign to builder agent with this spec as reference
- Schedule external translator for Phase 2
Questions for Alem:
- Approve next-intl as framework choice?
- Defer Swedish to post-MVP or include in initial release?
- Budget for professional legal document translation (English)?
- Timeline preference: Fast rollout (2 weeks) vs thorough rollout (4 weeks)?