Internazionalizzazione di THEJORD: Come Abbiamo Implementato i18n con Next.js
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
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')}
)
}
Language Switcher Component
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useIntl } from '@/components/IntlProvider'
export function LanguageSwitcher() {
const { locale } = useIntl()
const pathname = usePathname()
const router = useRouter()
const switchLocale = (newLocale: string) => {
// Replace /it/... with /en/...
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`)
// Set cookie for persistence
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
router.push(newPath)
}
return (
)
}
Content Localization
Blog Posts con Translation Groups
Ogni blog post ha un `translationGroup` che collega versioni in lingue diverse:
// Database schema
interface BlogPost {
id: string
slug: string // diff-checker-confronta-testi (IT) / diff-checker-compare-texts (EN)
language: 'it' | 'en'
translationGroup: string // 'diff-checker-001' (shared across translations)
title: string
content: string
metaTitle: string
metaDescription: string
// ...
}
// app/[locale]/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: { locale: string; slug: string }
}) {
const post = await getPostBySlugAndLocale(params.slug, params.locale)
// Get alternative language version
const altPost = await getAlternativeTranslation(
post.translationGroup,
params.locale === 'it' ? 'en' : 'it'
)
return (
{/* hreflang for SEO */}
{altPost && (
)}
{post.title}
{/* Link to translation */}
{altPost && (
{altPost.language === 'en' ? '🇬🇧 Read in English' : '🇮🇹 Leggi in Italiano'}
)}
)
}
SEO con hreflang
// app/[locale]/layout.tsx
export async function generateMetadata({
params,
}: {
params: { locale: string }
}) {
return {
alternates: {
canonical: `https://thejord.it/${params.locale}`,
languages: {
'it-IT': 'https://thejord.it/it',
'en-US': 'https://thejord.it/en',
'x-default': 'https://thejord.it/en',
},
},
}
}
Formattazione Locali
Date e Numeri
// utils/format.ts
export function formatDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale === 'it' ? 'it-IT' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function formatNumber(num: number, locale: string): string {
return new Intl.NumberFormat(locale === 'it' ? 'it-IT' : 'en-US').format(num)
}
// Usage
formatDate(new Date('2024-12-02'), 'it') // "2 dicembre 2024"
formatDate(new Date('2024-12-02'), 'en') // "December 2, 2024"
formatNumber(1234.56, 'it') // "1.234,56"
formatNumber(1234.56, 'en') // "1,234.56"
Pluralization
// utils/pluralize.ts
export function pluralize(
count: number,
locale: string,
options: { one: string; other: string }
): string {
const pluralRules = new Intl.PluralRules(locale)
const rule = pluralRules.select(count)
return options[rule] || options.other
}
// Usage in Italian
pluralize(1, 'it', { one: '1 file', other: '{{count}} file' }) // "1 file"
pluralize(5, 'it', { one: '1 file', other: '{{count}} file' }) // "5 file"
// Usage in English
pluralize(1, 'en', { one: '1 file', other: '{{count}} files' }) // "1 file"
pluralize(5, 'en', { one: '1 file', other: '{{count}} files' }) // "5 files"
Best Practices Adottate
1. Translation Keys Strutturate
✅ GOOD: Namespace gerarchico
{
"tools": {
"diffChecker": {
"title": "Diff Checker",
"actions": {
"compare": "Confronta",
"clear": "Pulisci"
}
}
}
}
❌ BAD: Flat keys
{
"diffCheckerTitle": "Diff Checker",
"diffCheckerCompare": "Confronta",
"diffCheckerClear": "Pulisci"
}
2. Evitare Hardcoded Strings
// ❌ BAD
// ✅ GOOD
3. Parametrizzazione Messaggi
// Translation
{
"blog": {
"publishedOn": "Pubblicato il {{date}} da {{author}}"
}
}
// Usage
t('blog.publishedOn', {
date: formatDate(post.publishedAt, locale),
author: post.author
})
4. RTL Support (Future)
// Preparazione per lingue RTL (arabo, ebraico)
function isRTL(locale: string): boolean {
return ['ar', 'he', 'fa'].includes(locale)
}
Sfide Affrontate
1. Dynamic Imports per Locale Files
Problema: Caricare tutti i locale files aumenta bundle size
// ❌ BAD: Import statico
import itTranslations from '@/i18n/locales/it.json'
import enTranslations from '@/i18n/locales/en.json'
// ✅ GOOD: Dynamic import
async function getMessages(locale: string) {
const messages = await import(`@/i18n/locales/${locale}.json`)
return messages.default
}
2. SEO Duplicate Content
Problema: Google potrebbe penalizzare contenuti simili in lingue diverse
Soluzione: hreflang tags + canonical URLs
3. Translation Workflow
Problema: Mantenere traduzioni sincronizzate durante sviluppo attivo
Soluzione: Script di validazione pre-commit
// scripts/validate-translations.ts
import itTranslations from '../i18n/locales/it.json'
import enTranslations from '../i18n/locales/en.json'
function getAllKeys(obj: any, prefix = ''): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key
return typeof value === 'object'
? getAllKeys(value, fullKey)
: [fullKey]
})
}
const itKeys = new Set(getAllKeys(itTranslations))
const enKeys = new Set(getAllKeys(enTranslations))
const missingInEn = [...itKeys].filter(k => !enKeys.has(k))
const missingInIt = [...enKeys].filter(k => !itKeys.has(k))
if (missingInEn.length > 0) {
console.error('Missing in EN:', missingInEn)
process.exit(1)
}
if (missingInIt.length > 0) {
console.error('Missing in IT:', missingInIt)
process.exit(1)
}
console.log('✅ All translations synchronized')
Metriche e Risultati
Traffic Distribution per Lingua
Dati primi 30 giorni post-lancio:
- 🇮🇹 Italiano: 68% del traffic (target audience primario)
- 🇬🇧 Inglese: 32% del traffic (mercato internazionale)
SEO Performance
- Google index: 40 pages (20 IT + 20 EN)
- Ranking keywords IT: 15+ in top 10
- Ranking keywords EN: 8+ in top 10
- Organic CTR: 4.2% (IT), 3.1% (EN)
Prossimi Passi
Q2 2025: Espansione Lingue
- 🇫🇷 Francese
- 🇪🇸 Spagnolo
- 🇩🇪 Tedesco
Miglioramenti Pianificati
- Automatic translation suggestions con AI (DeepL API)
- Translation management dashboard per contributors
- A/B testing copy varianti per ogni lingua
- Regional content (es. formati data/ora specifici per US vs UK)
Conclusioni
L'internazionalizzazione di THEJORD ha richiesto pianificazione attenta ma ha portato benefici evidenti:
- ✅ Reach globale: 32% traffic da paesi non-italiani
- ✅ SEO: 2x keywords ranking vs monolingual
- ✅ User satisfaction: Feedback positivo su localization
- ✅ Scalabilità: Architettura pronta per nuove lingue