Internationalizing THEJORD: How We Implemented i18n with Next.js

THEJORD Team10 min read
nextjsi18ninternationalizationmultilingualtechnical

THEJORD i18n implementation with Next.js 16: multilingual routing, locale detection middleware, translation management, SEO with hreflang and best practices.

Internationalizing THEJORD: How We Implemented i18n with Next.js

Internationalizing THEJORD: How We Implemented i18n with Next.js

THEJORD.IT is available in Italian and English from launch, with plans to expand to other European languages soon. In this technical article, we explore how we implemented internationalization (i18n) with Next.js 16, the challenges faced, and the best practices adopted.

Why Multilingual Matters

Internationalization isn't just translating text. It's making the product accessible, culturally appropriate, and SEO-friendly for different markets:

  • Accessibility: 90% of users prefer content in their native language
  • SEO: Better Google ranking for local queries (e.g., "confronta testi" vs "compare text")
  • User Experience: Local formats (dates, numbers, currencies) improve UX
  • Market Expansion: Open to non-English markets (Europe, LATAM, Asia)
  • Legal Compliance: Some regions require content in local language

i18n Goals for THEJORD

  • ✅ Italian and English support at launch
  • ✅ Instant language switching without reload
  • ✅ SEO-friendly localized URLs (thejord.it/it/tools vs /en/tools)
  • ✅ Language-specific content (translated blog posts)
  • ✅ Local formats (dates, numbers)
  • 🔜 Expansion to French, Spanish, German (Q2 2025)

THEJORD i18n Architecture

Next.js App Router with [locale] Segment

THEJORD uses the `[locale]` pattern for multilingual routing:

app/
├── [locale]/                  # Dynamic locale segment
│   ├── layout.tsx             # Root layout (locale provider)
│   ├── page.tsx               # Homepage (/) → redirect to /it or /en
│   ├── tools/
│   │   ├── diff-checker/
│   │   │   └── page.tsx       # /it/tools/diff-checker or /en/tools/diff-checker
│   │   └── ...
│   ├── blog/
│   │   ├── [slug]/
│   │   │   └── page.tsx       # /en/blog/diff-checker-compare-texts
│   │   └── page.tsx
│   └── not-found.tsx          # Localized 404
├── middleware.ts              # Locale detection & redirect
└── i18n/
    ├── locales/
    │   ├── it.json            # Italian translations
    │   └── en.json            # English translations
    ├── config.ts              # i18n configuration
    └── server.ts              # Server-side i18n utilities

Middleware for Locale Detection

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match as matchLocale } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'

const locales = ['it', 'en']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  // 1. Check URL path (highest priority)
  const pathname = request.nextUrl.pathname
  const pathnameLocale = locales.find(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  if (pathnameLocale) return pathnameLocale

  // 2. Check cookie
  const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
  if (localeCookie && locales.includes(localeCookie)) {
    return localeCookie
  }

  // 3. Check Accept-Language header
  const headers = { 'accept-language': request.headers.get('accept-language') || '' }
  const languages = new Negotiator({ headers }).languages()

  try {
    return matchLocale(languages, locales, defaultLocale)
  } catch {
    return defaultLocale
  }
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname

  // Skip middleware for static files
  if (
    pathname.startsWith('/_next') ||
    pathname.includes('/api/') ||
    pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js)$/)
  ) {
    return NextResponse.next()
  }

  // Check if locale is in pathname
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (pathnameHasLocale) {
    return NextResponse.next()
  }

  // Redirect to localized URL
  const locale = getLocale(request)
  request.nextUrl.pathname = `/${locale}${pathname}`

  const response = NextResponse.redirect(request.nextUrl)
  response.cookies.set('NEXT_LOCALE', locale, { maxAge: 31536000 }) // 1 year

  return response
}

export const config = {
  matcher: [
    // Match all paths except static files and API
    '/((?!_next|api|favicon.ico).*)',
  ],
}

Translation Files (JSON)

// i18n/locales/en.json
{
  "common": {
    "tools": "Tools",
    "blog": "Blog",
    "darkMode": "Dark Mode",
    "language": "Language"
  },
  "tools": {
    "diffChecker": {
      "title": "Diff Checker",
      "description": "Compare text and code",
      "placeholder1": "Paste first text here...",
      "placeholder2": "Paste second text here...",
      "compare": "Compare",
      "clear": "Clear",
      "copy": "Copy Result"
    },
    "hashGenerator": {
      "title": "Hash Generator",
      "description": "Generate MD5, SHA-256, SHA-512 hashes",
      "selectAlgorithm": "Select Algorithm",
      "generate": "Generate Hash"
    }
  },
  "blog": {
    "readMore": "Read more",
    "publishedOn": "Published on {{date}}",
    "readTime": "{{time}} min read",
    "relatedPosts": "Related Posts"
  }
}

Client-Side Implementation

Context Provider for i18n

// app/[locale]/layout.tsx
import { IntlProvider } from '@/components/IntlProvider'
import { getMessages } from '@/i18n/server'

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { locale: string }
}) {
  const messages = await getMessages(params.locale)

  return (
    
      
        
          {children}
        
      
    
  )
}
// components/IntlProvider.tsx
'use client'

import { createContext, useContext } from 'react'

interface IntlContextValue {
  locale: string
  messages: Record
  t: (key: string, params?: Record) => string
}

const IntlContext = createContext(null)

export function IntlProvider({
  locale,
  messages,
  children,
}: {
  locale: string
  messages: Record
  children: React.ReactNode
}) {
  const t = (key: string, params?: Record) => {
    const keys = key.split('.')
    let value: any = messages

    for (const k of keys) {
      value = value?.[k]
    }

    if (typeof value !== 'string') {
      console.warn(`Translation missing: ${key}`)
      return key
    }

    // Simple parameter replacement
    if (params) {
      return value.replace(/\{\{(\w+)\}\}/g, (_, param) => params[param] || '')
    }

    return value
  }

  return (
    
      {children}
    
  )
}

export function useIntl() {
  const context = useContext(IntlContext)
  if (!context) {
    throw new Error('useIntl must be used within IntlProvider')
  }
  return context
}

Usage in Components

// components/DiffChecker.tsx
'use client'

import { useIntl } from '@/components/IntlProvider'

export function DiffChecker() {
  const { t } = useIntl()

  return (
    

{t('tools.diffChecker.title')}

{t('tools.diffChecker.description')}