Global Internationalization Completion — LanguageSwitcher UX Path + Full Page Translation + SEO Support

Sprint 122 — i18n UX Path Completion and Full Page Translation

Background

Sprint 121 built the next-intl-based i18n architecture and completed Landing/Auth demo 2-page translations. However, the following UX defects were confirmed post-merge and transferred to Sprint 122 as top priority work.

Transfer reasons (discovered after Sprint 121 close):

  • LanguageSwitcher was only integrated into AppLayout (post-login screens), making unauthenticated users (landing/Auth screen visitors) unable to switch language via UI
  • Directly entering /en/* URL renders landing in English, but other pages (dashboard, problems, etc.) remain untranslated
  • Hardcoded Korean strings remain in app/[locale]/layout.tsx (skip-nav), not-found.tsx, */error.tsx (21 files), components/ad/AdBanner.tsx, etc.

Sprint 122 goal: On the i18n foundation built by Sprint 121, transition to an actually globally-serviceable state via unauthenticated UX path completion + full major page translation + SEO hreflang support.


Key Decisions (D1~D3)

D1. LanguageSwitcher Placement Strategy

Problem: In Sprint 121, LanguageSwitcher was placed in AppLayout's sidebar/topbar. The landing page (LandingContent.tsx) and auth layout (app/[locale]/(auth)/layout.tsx) have no toggle button, so unauthenticated users cannot switch language via UI.

Selected:

PlacementImplementationRationale
LandingContent NavInsert in LandingContent.tsx header right area, between theme toggle and login buttonLanding visitors (unauthenticated) can select language immediately
AuthShell Client wrapperCreate new AuthShell (Client Component) in app/[locale]/(auth)/layout.tsx, insert as glass-nav header componentApplied across all Auth layout at once for login/callback/register etc.

generateMetadata placement: Auth layout's generateMetadata stays in Server Component layout.tsx. AuthShell is separated as Client Component handling only interaction (LanguageSwitcher). Clearly separating Server/Client boundary maintains metadata SEO benefit.

Comparison:

ApproachAdvantageDisadvantage
Convert all of layout.tsx to ClientSimple implementationCannot use generateMetadata, SEO loss
New AuthShell Client wrapper (selected)Maintains Server/Client boundary, keeps generateMetadataOne additional component layer
Per-page individual insertionFine-grained controlDuplicate code, need to modify all 21 Auth sub-pages

Trade-off: Separating AuthShell as Client Component enables using client hooks like useSearchParams, usePathname in the Auth layout tree. However, getTranslations() server function cannot be called directly inside AuthShell — translations must use useTranslations() hook.


D2. Namespace Domain Grouping

Problem: The 4 namespaces (common/landing/auth/difficulty) confirmed in Sprint 121 cannot accommodate translations for major pages like dashboard, problems, submissions, reviews. Namespace structure needs expansion for Sprint 122's full translation scope.

Selected — keep existing 4 + create 6 new:

NamespaceStatusContent
commonExisting (extended)Buttons/labels/loading + nav.* key extension (menu names, breadcrumbs, accessibility labels)
landingExisting unchangedHero, feature intro, CTA
authExisting unchangedLogin, OAuth errors, register
difficultyExisting unchangedDifficulty labels and tooltips
dashboardNewDashboard statistics widgets, recent submissions, analytics integration
problemsNewProblem list, detail, tags, difficulty filter UI
submissionsNewSubmission list, detail, status labels, result messages
reviewsNewPeer review list, review form, evaluation items
accountNewProfile page + settings page integrated (profile.*, settings.* sub-keys)
errorsNewCommon error page strings (not-found, 403/404/500, skip-nav)

common.nav.* extension detail:

common.nav.dashboard   → "대시보드" / "Dashboard"
common.nav.problems    → "문제" / "Problems"
common.nav.submissions → "제출" / "Submissions"
common.nav.reviews     → "리뷰" / "Reviews"
common.nav.profile     → "프로필" / "Profile"
common.nav.settings    → "설정" / "Settings"
common.nav.admin       → "관리자" / "Admin"
common.nav.skipToMain  → "본문으로 건너뛰기" / "Skip to main content"

errors.json + LocalizedErrorPage wrapper strategy:

Korean hardcoded strings are scattered across 21 */error.tsx files and not-found.tsx. Instead of modifying each file individually, create a LocalizedErrorPage common wrapper component and batch-replace all error.tsx files to import it.

components/error/LocalizedErrorPage.tsx  ← Client Component
  - uses useTranslations('errors')
  - props: errorCode (404|403|500|...), retry?: boolean
  - 21 error.tsx files replaced with <LocalizedErrorPage errorCode={...} />

errors.json key structure:

JSON
{
  "notFound": { "title": "...", "description": "...", "back": "..." },
  "forbidden": { "title": "...", "description": "..." },
  "serverError": { "title": "...", "description": "...", "retry": "..." },
  "generic": { "title": "...", "description": "...", "home": "..." }
}

Rationale: Modifying 21 error.tsx files individually creates excessive changed files and makes consistency hard to maintain. Centralized error UI management via single LocalizedErrorPage wrapper means only one file needs modification for future error message changes (OCP). Configured as Client Component to use useTranslations hook.

Trade-off: account namespace integrating profile and settings may result in larger file sizes. If both pages expand independently in the future, split account.profile.*/account.settings.* sub-keys into separate files.


D3. SEO Strategy — hreflang + metadataBase + sitemap

Problem: sitemap.xml hreflang tags, robots.txt, and metadataBase configuration are incomplete as Sprint 121 carry-overs. As English pages are added, SEO support is needed for search engines to correctly index locale-specific URLs.

Selected:

1) metadataBaseNEXT_PUBLIC_BASE_URL environment variable

Manage base URL for all metadata via environment variable:

TypeScript
// app/[locale]/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? 'https://algosu.kr'),
};

Rationale: Remove hardcoded domain strings and separate URLs per staging/production environment via environment variables.

2) buildLocaleAlternates helper — src/lib/i18n/metadata.ts

Create a new helper function that generates locale-specific alternates.languages objects for reuse in each page's generateMetadata:

TypeScript
// src/lib/i18n/metadata.ts
/**
 * @file src/lib/i18n/metadata.ts
 * @domain i18n
 * @layer lib
 * @related src/i18n/routing.ts, app/[locale]/layout.tsx
 */

/**
 * Generates hreflang alternates object per locale.
 * @param locale - current locale ('ko' | 'en')
 * @param path - path (e.g., '/problems', '/dashboard')
 * @returns Next.js Metadata alternates.languages format
 */
export function buildLocaleAlternates(
  locale: string,
  path: string,
): Record<string, string> {
  const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://algosu.kr';
  return {
    ko: `${base}${path}`,
    en: `${base}/en${path}`,
    'x-default': `${base}${path}`,
  };
}

Usage example:

TypeScript
// app/[locale]/problems/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'problems' });
  return {
    title: t('meta.title'),
    alternates: {
      languages: buildLocaleAlternates(locale, '/problems'),
    },
  };
}

3) app/sitemap.ts — alternates.languages hreflang

Auto-generate locale-specific URL pairs in Next.js App Router's sitemap.ts:

TypeScript
// app/sitemap.ts (conceptual)
export default function sitemap(): MetadataRoute.Sitemap {
  const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://algosu.kr';
  const paths = ['/problems', '/dashboard', '/submissions', '/reviews'];
  return paths.map((path) => ({
    url: `${base}${path}`,
    alternates: {
      languages: {
        ko: `${base}${path}`,
        en: `${base}/en${path}`,
      },
    },
  }));
}

4) New app/robots.ts

Dynamically generate robots.txt specifying allowed/disallowed search engine crawling paths:

TypeScript
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://algosu.kr';
  return {
    rules: { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
    sitemap: `${base}/sitemap.xml`,
  };
}

Comparison:

ApproachAdvantageDisadvantage
Static public/sitemap.xmlSimpleManual update needed when pages are added
app/sitemap.ts dynamic generation (selected)Auto-reflects when pages addedRequires build-time execution
External sitemap generatorFully automatedAdditional tool dependency

Trade-off: Dynamic page URLs (problem detail, submission detail, etc.) require DB queries, so Sprint 122 scope only includes static paths. Dynamic path hreflang is handled via generateSitemaps extension in Sprint 123.


Scope Decisions

Sprint 122 Scope — Page Level + P0 Processing

CategoryIncludedReason
LanguageSwitcher UX path improvementP0 UX defect for unauthenticated users
Hardcoded Korean cleanup (layout, not-found, 21 error.tsx, AdBanner)Korean remaining in English mode is P0 visual bug
Major 6-page translations (dashboard/problems/submissions/reviews/profile/settings)Sprint 122 core goal
SEO support (metadataBase, buildLocaleAlternates, sitemap.ts, robots.ts)Sprint 121 carry-over
register 3-page translations✅ (Critic seed processing)Auth path completeness
Component-level 53 individual translations❌ → Sprint 123 carry-overToo broad, apply progressively after page-level completion
AuthContext locale-aware transition✅ (Critic seed)window.location.hrefuseRouter
CI translation key parity check✅ (Critic seed)Auto-detect ko/en key mismatches
Dynamic translation key type safety⬜ → decide after reviewnext-intl type plugin effort unknown
renderWithI18n test migration⬜ → Sprint 123Prioritize existing test stability
Backend OAuth error code internationalization❌ → Sprint 123+Separate backend strategy needed

Component 53 Sprint 123 Carry-Over Rationale

Korean hardcoding within components identified during Sprint 121 Phase B app/[locale]/* reorganization spans 53 files. Batch-processing in Sprint 122 would:

  • Excessive changed files → reduced PR review quality
  • No verification baseline for component pre-processing before page-level translations are complete

Principle: Apply sequentially, starting from components whose page-level translations (+ namespaces) are complete. Proceed alongside renderWithI18n test migration in Sprint 123.


Phase Plan (A~H)

PhaseAgentContentDependencies
AScribeWrite and confirm ADR D1~D3 (this document)
BArchitectInitialize 6 new namespace JSON files (ko/en) + common.nav.* extensionPhase A
CPaletteLanguageSwitcher UX path — LandingContent Nav insertion + new AuthShellPhase A
DPaletteBatch hardcoded Korean cleanup — new LocalizedErrorPage wrapper + replace 21 error.tsx + skip-nav/not-found/AdBannerPhase B
EPaletteMajor page translation — dashboard/problems/submissions/reviewsPhase B
FPaletteAccount page translations — profile/settings/register 3 pagesPhase B
GArchitectSEO support — src/lib/i18n/metadata.ts helper + sitemap.ts + robots.ts + generateMetadata alternates for each pagePhase E/F
HGatekeeperCritic carry-over seed processing — AuthContext locale-aware transition + CI key parity check + Sprint 120 carry-over P1 3 itemsPhase C

Phase Execution Results

PhaseContentResult
ADesign decisions (D1~D3) ADR confirmed + Scout full scan (96 app files, 53 components, initial i18n application 8% → page-level 100% achieved)
BLanguageSwitcher UX path complete — LandingContent Nav header right insertion + new AuthShell Client Component
CP0 hardcoded batch processing — skip-nav, errors.json 12 page keys, LocalizedErrorPage wrapper batch-replacing 21 error.tsx, not-found 3 Server Component conversions, AdBanner translations
D Wave 1dashboard + analytics translations (D-0/D-1/D-2, dashboard.json ~80 keys)
D Wave 2problems domain translations (D-3a/b/c/d, problems.json ~80 keys, 4 pages)
D Wave 3submissions domain translations (D-4a/b/c/d, submissions.json, 3 pages)
D Wave 4reviews translations (D-5a/b, reviews.json, 1 page)
D Wave 5account translations (D-6a/b/c, account.json, profile + profile/[slug] + settings)
ESEO — buildLocaleAlternates helper, metadataBase, sitemap.ts hreflang, robots.ts
H-1ADR finalization + memory update

Cumulative namespaces 10: common, landing, auth, difficulty, errors, dashboard, problems, submissions, reviews, account Wave D total commits: 14 commits Final commit: 37c8eb2 (Phase E)


Critic Review History

RoundReview IDTargetKey FindingsResolution
1st145430Phase B+C 8 commitsP2 1 item — not-found provider missingImmediately resolved via fix-palette commit
2nd145430-66123fix 3 commitsMedium 2 + Low 1All judged as pre-existing or non-blocking, no additional action needed
Wave 1~5 individualWave D 14 commitsauto-critic triggeredIndividual results to be separately confirmed (not aggregated)

Verification

ItemResult
tsc --noEmit✅ PASS
ESLint✅ PASS
jest✅ PASS
Critic Critical/High0 items

Sprint 123 Carry-Over Seeds

List of items intentionally excluded from Sprint 122 scope and transferred to Sprint 123.

Component Translations (53 files)

In priority order:

  • AppLayout / TopNav / StudySidebar / NotificationBell
  • Dashboard* / Analytics* widget components
  • Feedback* / Review* / Submission*
  • ShareLinkManager and other shared components

Untranslated Pages

PageReason
admin/problems/[id]/edit, admin/problems/createadmin domain separate processing
admin/feedbacksadmin domain separate processing
problems/[id]/status (study statistics)Separate translation key design needed
studies/page, studies/[id]/page, studies/[id]/roomEntire studies domain separate Wave
guest/page, shared/[token]/pagePublic shared paths
privacy/termsTranslate after legal review

i18n Quality Improvements

ItemSource
renderWithI18n test migration full applicationCritic Low-2 (Sprint 121)
next-intl type plugin introduction (dynamic translation key type safety)Critic recommendation
Zod schema validation message i18n — errorMap pattern introduction reviewNew seed
lib/date.ts relative time useFormatter migrationNew seed
hooks/useSubmissionSSE dynamic translation caller-level migrationNew seed
studies/[id]/room/utils.ts Korean in pure TS utilitiesNew seed
lib/api/client.ts HTTP error message internationalizationNew seed

Security and Backend

ItemSource
Sprint 120 carry-over Frontend P1 3 items (p1-023/024/025)Sprint 120 unprocessed
P1 security 49 itemsSprint 118/119 batches
Backend OAuth error structuring (Sprint 121 M-E2 root fix)Separate ADR needed

Critic-Identified Anti-patterns

ItemContent
Remove code: '404' translation keyAnti-pattern using numeric key as string — replace with semantic keys like notFound