Internazionalizzazione di THEJORD: Come Abbiamo Implementato i18n con Next.js

THEJORD Team10 min di lettura
nextjsi18ninternationalizationmultilingualtechnical

Implementazione i18n di THEJORD con Next.js 16: routing multilingua, middleware per locale detection, translation management, SEO con hreflang e best practices.

Internazionalizzazione di THEJORD: Come Abbiamo Implementato i18n con Next.js

Internazionalizzazione di THEJORD: Come Abbiamo Implementato i18n con Next.js

THEJORD.IT è disponibile in italiano e inglese fin dal lancio, con l'obiettivo di espanderci presto ad altre lingue europee. In questo articolo tecnico esploriamo come abbiamo implementato l'internazionalizzazione (i18n) con Next.js 16, le sfide affrontate e le best practices adottate.

Perché Multilingua è Importante

L'internazionalizzazione non è solo tradurre testi. È rendere il prodotto accessibile, culturalmente appropriato e SEO-friendly per mercati diversi:

  • Accessibilità: 90% degli utenti preferisce contenuti nella propria lingua
  • SEO: Google ranking migliore per query locali (es. "confronta testi" vs "compare text")
  • User Experience: Formati locali (date, numeri, valute) migliorano UX
  • Market Expansion: Aprire a mercati non anglofoni (Europa, LATAM, Asia)
  • Legal Compliance: Alcune regioni richiedono contenuti in lingua locale

Obiettivi di i18n per THEJORD

  • ✅ Supporto italiano e inglese al lancio
  • ✅ Switch lingua istantaneo senza reload
  • ✅ URL localizzati SEO-friendly (thejord.it/it/tools vs /en/tools)
  • ✅ Contenuti specifici per lingua (blog posts tradotti)
  • ✅ Formati locali (date, numeri)
  • 🔜 Espansione a francese, spagnolo, tedesco (Q2 2025)

Architettura i18n di THEJORD

Next.js App Router con [locale] Segment

THEJORD usa il pattern `[locale]` per routing multilingua:

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       # /it/blog/diff-checker-confronta-testi
│   │   └── 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 per 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 = 'it'

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/it.json
{
  "common": {
    "tools": "Strumenti",
    "blog": "Blog",
    "darkMode": "Modalità Scura",
    "language": "Lingua"
  },
  "tools": {
    "diffChecker": {
      "title": "Diff Checker",
      "description": "Confronta testi e codice",
      "placeholder1": "Incolla il primo testo qui...",
      "placeholder2": "Incolla il secondo testo qui...",
      "compare": "Confronta",
      "clear": "Pulisci",
      "copy": "Copia Risultato"
    },
    "hashGenerator": {
      "title": "Hash Generator",
      "description": "Genera hash MD5, SHA-256, SHA-512",
      "selectAlgorithm": "Seleziona Algoritmo",
      "generate": "Genera Hash"
    }
  },
  "blog": {
    "readMore": "Leggi di più",
    "publishedOn": "Pubblicato il {{date}}",
    "readTime": "{{time}} min di lettura",
    "relatedPosts": "Articoli Correlati"
  }
}
// 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"
  }
}

Implementazione Client-Side

Context Provider per i18n

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

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.Node
  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')}