Skip to main content

Internationalization (i18n)

KnowledgeCheckr uses next-international v1.3.1 for type-safe internationalization with support for English and German locales.

Overview

Next-international provides:
  • Type-safe translations
  • Server and client component support
  • Automatic locale routing
  • Scoped translations
  • Dynamic locale switching
  • Pluralization support

Configuration

i18n Config

Location: src/i18n/i18nConfig.ts
import { createI18nMiddleware } from 'next-international/middleware'

export const locales = ['en', 'de'] as const

export type i18nLocale = (typeof locales)[number]

const i18nConfig: Parameters<typeof createI18nMiddleware<typeof locales>>['0'] = {
  locales,
  defaultLocale: 'en',
  urlMappingStrategy: 'rewriteDefault', // hides default locale from path
}

export default i18nConfig
Supported Locales:
  • en - English (default)
  • de - German (Deutsch)
URL Strategy: rewriteDefault
  • English URLs: /checks (no locale prefix)
  • German URLs: /de/checks (locale prefix)

Server-Side i18n

Location: src/i18n/server-localization.ts
import { createI18nServer } from 'next-international/server'

export const { 
  getI18n, 
  getScopedI18n, 
  getCurrentLocale, 
  getStaticParams 
} = createI18nServer({
  en: () => import('./locales/en'),
  de: () => import('./locales/de'),
})

Server Component Usage

import { getI18n, getCurrentLocale } from '@/src/i18n/server-localization'

export default async function ChecksPage() {
  const t = await getI18n()
  const locale = await getCurrentLocale()
  
  return (
    <div>
      <h1>{t('Checks.title')}</h1>
      <p>Current locale: {locale}</p>
    </div>
  )
}

Scoped Translations

Reduce repetition with scoped translations:
import { getScopedI18n } from '@/src/i18n/server-localization'

export default async function ExaminationSettings() {
  const t = await getScopedI18n('Checks.Create.SettingSection.ExaminationSettings')
  
  return (
    <div>
      <h2>{t('title')}</h2>
      <label>{t('allowAnonymous_label')}</label>
      <label>{t('questionOrder_label')}</label>
    </div>
  )
}

Static Params Generation

Generate static paths for all locales:
import { getStaticParams } from '@/src/i18n/server-localization'

export function generateStaticParams() {
  return getStaticParams()
}

// Generates: [{ locale: 'en' }, { locale: 'de' }]

Client-Side i18n

Location: src/i18n/client-localization.ts
import { createI18nClient } from 'next-international/client'

export const { 
  useI18n, 
  useScopedI18n, 
  I18nProviderClient, 
  useChangeLocale, 
  defineLocale, 
  useCurrentLocale 
} = createI18nClient({
  en: async () => {
    await new Promise((resolve) => setTimeout(resolve, 100))
    return import('./locales/en')
  },
  de: async () => {
    await new Promise((resolve) => setTimeout(resolve, 100))
    return import('./locales/de')
  },
})
Note: 100ms delay is added for smooth locale switching animations.

Client Component Usage

'use client'

import { useI18n, useCurrentLocale } from '@/src/i18n/client-localization'

export function CreateCheckButton() {
  const t = useI18n()
  const locale = useCurrentLocale()
  
  return (
    <button>
      {t('Checks.Discover.FilterFields.create_check_button_label')}
    </button>
  )
}

Scoped Client Translations

'use client'

import { useScopedI18n } from '@/src/i18n/client-localization'

export function QuestionCard({ question }) {
  const t = useScopedI18n('Components.KnowledgeCheckCard')
  
  return (
    <div>
      <p>{t('last_modified_label')}: {question.updatedAt}</p>
    </div>
  )
}

Locale Switching

'use client'

import { useChangeLocale, useCurrentLocale } from '@/src/i18n/client-localization'

export function LanguageSwitcher() {
  const changeLocale = useChangeLocale()
  const currentLocale = useCurrentLocale()
  
  return (
    <div>
      <button
        onClick={() => changeLocale('en')}
        disabled={currentLocale === 'en'}
      >
        English
      </button>
      <button
        onClick={() => changeLocale('de')}
        disabled={currentLocale === 'de'}
      >
        Deutsch
      </button>
    </div>
  )
}

Translation Files

Locale Structure

Location: src/i18n/locales/en.ts (auto-generated)
export default {
  Shared: {
    navigation_button_next: 'Next',
    navigation_button_previous: 'Previous',
    Question: {
      question_label: 'Question',
      type_label: 'Question Type',
      type: {
        'multiple-choice': 'Multiple-Choice',
        'single-choice': 'Single-Choice',
        'open-question': 'Open-Question',
        'drag-drop': 'Drag-Drop'
      },
    },
  },
  Checks: {
    title: 'Your Checks',
    Create: {
      GeneralSection: {
        title: 'General Section',
        name_label: 'Name',
        name_placeholder: 'Science Fiction Check',
      },
    },
  },
  Components: {
    KnowledgeCheckCard: {
      last_modified_label: 'last modified',
    },
  },
} as const
Note: Files are auto-generated - see Locale Management below.

Pluralization

Support for plural forms:
// In locale file
Checks: {
  Create: {
    QuestionSection: {
      QuestionCard: {
        'points#one': '{count} point',
        'points#other': '{count} points',
      },
    },
  },
}

// Usage
const t = await getI18n()
t('Checks.Create.QuestionSection.QuestionCard.points', { count: 1 }) // "1 point"
t('Checks.Create.QuestionSection.QuestionCard.points', { count: 5 }) // "5 points"

Variable Interpolation

// In locale file
Examination: {
  attempt_not_possible: {
    checkClosed: 'Check was closed on {closeDate}',
    notOpenYet: 'Check opens on {openDate}',
  },
}

// Usage
const t = await getI18n()
t('Examination.attempt_not_possible.checkClosed', { 
  closeDate: '2024-12-31' 
})
// "Check was closed on 2024-12-31"

Layout Integration

Locale Layout

Location: src/app/[locale]/layout.tsx
import React from 'react'
import { I18nProvider } from '@/src/components/root/I18nProvider'

export default async function LocaleRootLayout({ 
  children 
}: { 
  children: React.ReactNode 
}) {
  return <I18nProvider>{children}</I18nProvider>
}

I18nProvider Component

'use client'

import { ReactNode } from 'react'
import { I18nProviderClient } from '@/src/i18n/client-localization'
import { useParams } from 'next/navigation'

export function I18nProvider({ children }: { children: ReactNode }) {
  const params = useParams()
  const locale = params.locale as 'en' | 'de'
  
  return (
    <I18nProviderClient locale={locale}>
      {children}
    </I18nProviderClient>
  )
}

Locale Management

Source Files

Translations are managed in JSON files:
src/i18n/locales/
├── en.json    # English translations (source)
├── de.json    # German translations (source)
├── en.ts      # Auto-generated TypeScript (for type safety)
└── de.ts      # Auto-generated TypeScript

Auto-Generation Script

Location: src/i18n/scripts/prepare-i18n_ally-locales.ts Converts JSON locale files to TypeScript with type safety:
# Run manually
yarn tsx --conditions=react-server src/i18n/scripts/prepare-i18n_ally-locales.ts

# Automatically during development (via nodemon)
yarn dev
From package.json:9:
"dev": "concurrently 'next dev --turbopack' 'nodemon --exec \"yarn tsx --conditions=react-server src/i18n/scripts/prepare-i18n_ally-locales.ts\" --watch src/i18n/locales/*.json'"
Behavior:
  • Watches *.json files in src/i18n/locales/
  • Generates corresponding .ts files
  • Adds as const for TypeScript type inference
  • Auto-generated files include warning comment

Adding New Translations

  1. Edit src/i18n/locales/en.json (source of truth)
  2. Edit src/i18n/locales/de.json (German translation)
  3. TypeScript files regenerate automatically in dev mode
  4. Use translations with full type safety
Example:
// src/i18n/locales/en.json
{
  "Settings": {
    "theme_label": "Theme",
    "theme_dark": "Dark",
    "theme_light": "Light"
  }
}
// src/i18n/locales/de.json
{
  "Settings": {
    "theme_label": "Design",
    "theme_dark": "Dunkel",
    "theme_light": "Hell"
  }
}

Type Safety

Translation Path Autocomplete

TypeScript provides autocomplete for translation keys:
const t = await getI18n()

// ✅ Valid - TypeScript knows these keys exist
t('Checks.title')
t('Components.KnowledgeCheckCard.last_modified_label')

// ❌ Invalid - TypeScript error
t('NonExistent.key')
t('Checks.typo')

Compile-Time Validation

Missing translations are caught at build time:
npm run build
# Error: Property 'new_feature_label' does not exist on type '...'

Type-Safe Parameters

Variable interpolation is type-checked:
// ✅ Correct
t('Examination.attempt_not_possible.checkClosed', { closeDate: '2024-12-31' })

// ❌ Type error - missing required parameter
t('Examination.attempt_not_possible.checkClosed')

// ❌ Type error - wrong parameter name
t('Examination.attempt_not_possible.checkClosed', { wrongParam: '2024-12-31' })

Middleware Setup

Next-international requires middleware for locale detection: Location: src/middleware.ts
import { createI18nMiddleware } from 'next-international/middleware'
import i18nConfig from '@/src/i18n/i18nConfig'

const i18nMiddleware = createI18nMiddleware(i18nConfig)

export default function middleware(request: NextRequest) {
  return i18nMiddleware(request)
}

export const config = {
  matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
}
Behavior:
  • Detects locale from URL path
  • Redirects / to / (en) or /de based on browser settings
  • Rewrites paths to include locale in route params

Locale Detection

Priority Order

  1. URL Path: /de/checksde
  2. Cookie: NEXT_LOCALE=de
  3. Accept-Language Header: Browser preference
  4. Default: en

Example Flow

User visits: https://knowledgecheckr.com/
Browser language: de-DE

1. Middleware checks URL: No locale prefix
2. Middleware checks cookie: Not set
3. Middleware checks Accept-Language: de-DE
4. Middleware redirects to: /de/
5. Sets cookie: NEXT_LOCALE=de

Development Workflow

Starting Development

yarn dev
This command:
  1. Starts Next.js dev server with Turbopack
  2. Starts nodemon to watch JSON locale files
  3. Auto-regenerates TypeScript files on changes

Adding a New Locale

  1. Update config (src/i18n/i18nConfig.ts):
    export const locales = ['en', 'de', 'fr'] as const
    
  2. Create locale file (src/i18n/locales/fr.json):
    {
      "Shared": {
        "navigation_button_next": "Suivant",
        "navigation_button_previous": "Précédent"
      }
    }
    
  3. Update server localization (src/i18n/server-localization.ts):
    export const { ... } = createI18nServer({
      en: () => import('./locales/en'),
      de: () => import('./locales/de'),
      fr: () => import('./locales/fr'),
    })
    
  4. Update client localization (src/i18n/client-localization.ts):
    export const { ... } = createI18nClient({
      en: async () => { ... },
      de: async () => { ... },
      fr: async () => { ... },
    })
    
  5. Restart dev server to regenerate types

Best Practices

1. Use Scoped Translations

Reduce repetition in components:
// ❌ Not ideal
const t = await getI18n()
return (
  <>
    {t('Checks.Create.SettingSection.ExaminationSettings.title')}
    {t('Checks.Create.SettingSection.ExaminationSettings.allowAnonymous_label')}
  </>
)

// ✅ Better
const t = await getScopedI18n('Checks.Create.SettingSection.ExaminationSettings')
return (
  <>
    {t('title')}
    {t('allowAnonymous_label')}
  </>
)

2. Organize by Feature

Group translations by feature area:
{
  "Checks": { ... },      // Check-related translations
  "Examination": { ... }, // Exam-related translations
  "Practice": { ... },    // Practice-related translations
  "Components": { ... },  // Reusable component translations
  "Shared": { ... }       // Globally shared translations
}

3. Use Meaningful Keys

Prefer descriptive keys over generic ones:
// ❌ Not ideal
{
  "button1": "Next",
  "text2": "Your Checks"
}

// ✅ Better
{
  "navigation_button_next": "Next",
  "page_title_checks": "Your Checks"
}

4. Keep Translations Flat When Possible

Avoid excessive nesting:
// ❌ Too nested
{
  "Pages": {
    "Checks": {
      "Index": {
        "Header": {
          "Title": "Checks"
        }
      }
    }
  }
}

// ✅ Better
{
  "Checks": {
    "title": "Checks"
  }
}

5. Server vs Client Components

  • Use server translations for static content
  • Use client translations for interactive elements
  • Prefer server components for better performance

Performance Considerations

Code Splitting

Locale files are automatically code-split:
  • English users only download en.ts
  • German users only download de.ts

Server-Side Rendering

Translations are rendered on the server:
  • No flash of untranslated content
  • SEO-friendly translated content
  • Faster initial page load

Client Hydration

Client components receive pre-translated content:
// Server component passes translated text as prop
<ClientButton label={t('button_label')} />

// Better than client-side translation
<ClientButton /> // Requires client-side i18n load

Testing

Test with Specific Locale

import { getI18n } from '@/src/i18n/server-localization'

test('displays German translation', async () => {
  const t = await getI18n('de')
  expect(t('Checks.title')).toBe('Deine Checks')
})

Test Locale Switching

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('switches locale', async () => {
  render(<LanguageSwitcher />)
  
  const deButton = screen.getByText('Deutsch')
  await userEvent.click(deButton)
  
  expect(window.location.pathname).toBe('/de')
})

Troubleshooting

TypeScript Not Recognizing New Keys

  1. Ensure JSON files are saved
  2. Check nodemon is running (via yarn dev)
  3. Restart TypeScript server in your IDE
  4. Manually run: yarn tsx src/i18n/scripts/prepare-i18n_ally-locales.ts

Translations Not Updating

  1. Clear .next cache: rm -rf .next
  2. Restart dev server
  3. Hard refresh browser (Ctrl+Shift+R)

Missing Translation Fallback

Next-international returns the key if translation is missing:
t('NonExistent.key') // Returns "NonExistent.key"
Always check console for warnings about missing keys.

Next Steps

Build docs developers (and LLMs) love