Internationalizing THEJORD: How We Implemented i18n with Next.js
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
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')}
)
}
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 /en/... with /it/...
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 with Translation Groups
Each blog post has a `translationGroup` linking versions in different languages:
// 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 === 'it' ? '🇮🇹 Leggi in Italiano' : '🇬🇧 Read in English'}
)}
)
}
SEO with 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',
},
},
}
}
Local Formatting
Dates and Numbers
// 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 English
pluralize(1, 'en', { one: '1 file', other: '{{count}} files' }) // "1 file"
pluralize(5, 'en', { one: '1 file', other: '{{count}} files' }) // "5 files"
// 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"
Best Practices Adopted
1. Structured Translation Keys
✅ GOOD: Hierarchical namespace
{
"tools": {
"diffChecker": {
"title": "Diff Checker",
"actions": {
"compare": "Compare",
"clear": "Clear"
}
}
}
}
❌ BAD: Flat keys
{
"diffCheckerTitle": "Diff Checker",
"diffCheckerCompare": "Compare",
"diffCheckerClear": "Clear"
}
2. Avoid Hardcoded Strings
// ❌ BAD
// ✅ GOOD
3. Message Parameterization
// Translation
{
"blog": {
"publishedOn": "Published on {{date}} by {{author}}"
}
}
// Usage
t('blog.publishedOn', {
date: formatDate(post.publishedAt, locale),
author: post.author
})
4. RTL Support (Future)
// Preparation for RTL languages (Arabic, Hebrew)
function isRTL(locale: string): boolean {
return ['ar', 'he', 'fa'].includes(locale)
}
Challenges Faced
1. Dynamic Imports for Locale Files
Problem: Loading all locale files increases bundle size
// ❌ BAD: Static import
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
Problem: Google might penalize similar content in different languages
Solution: hreflang tags + canonical URLs
3. Translation Workflow
Problem: Keeping translations synchronized during active development
Solution: Pre-commit validation script
// 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')
Metrics and Results
Traffic Distribution by Language
Data from first 30 days post-launch:
- 🇮🇹 Italian: 68% of traffic (primary target audience)
- 🇬🇧 English: 32% of traffic (international market)
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)
Next Steps
Q2 2025: Language Expansion
- 🇫🇷 French
- 🇪🇸 Spanish
- 🇩🇪 German
Planned Improvements
- Automatic translation suggestions with AI (DeepL API)
- Translation management dashboard for contributors
- A/B testing copy variants for each language
- Regional content (e.g., date/time formats specific to US vs UK)
Conclusions
Internationalizing THEJORD required careful planning but brought evident benefits:
- ✅ Global reach: 32% traffic from non-Italian countries
- ✅ SEO: 2x keyword rankings vs monolingual
- ✅ User satisfaction: Positive feedback on localization
- ✅ Scalability: Architecture ready for new languages
Useful Resources
Frequently Asked Questions
What is Next.js?
A React framework for web applications with SSR, SSG and automatic routing.
What are the advantages of Next.js?
Improved SEO, optimized performance, excellent developer experience.