Un build multilenguaje es un problema de copy antes que un problema de routing. El routing, el hreflang, el scaffold del diccionario se resuelven todos para la tercera semana. El trabajo que queda es hacer que el producto se lea natural a un hablante nativo, y ese trabajo depende de que el copy fuente sea claro, de que el brief sea detallado, y de que el reviewer sea un nativo que distinga entre una traducción y una reescritura.
Los cinco archivos de abajo son la armadura que deja el encargo. El loader de diccionarios tipados, el middleware de locale routing, el helper de hreflang y canonical, los helpers de formato, y el guardarraíl de traducción en CI.
1. El loader de diccionarios tipados
El diccionario inglés es la fuente de verdad. Los demás idiomas importan el tipo inglés y tienen que implementar cada clave; las claves que falten son errores de compilación, no bugs en runtime que aparecen en producción.
// src/config/i18n/en.ts
export const en = {
nav: {
home: 'Home',
pricing: 'Pricing',
signIn: 'Sign in',
},
pricing: {
title: 'Pricing that scales with you',
subtitle: 'No setup fee. Cancel anytime.',
cta: 'Start free trial',
},
emails: {
welcome: {
subject: 'Welcome to {productName}',
body: 'You are in. The next step is …',
},
},
} as const
export type Dictionary = typeof en
// src/config/i18n/es.ts
import type { Dictionary } from './en'
export const es: Dictionary = {
nav: {
home: 'Inicio',
pricing: 'Precios',
signIn: 'Entrar',
},
pricing: {
title: 'Precios que crecen contigo',
subtitle: 'Sin coste de alta. Cancelas cuando quieras.',
cta: 'Empieza gratis',
},
emails: {
welcome: {
subject: 'Te damos la bienvenida a {productName}',
body: 'Estás dentro. El siguiente paso es …',
},
},
}
// src/config/i18n/index.ts
import { en } from './en'
import { it } from './it'
import { es } from './es'
export const locales = ['en', 'it', 'es'] as const
export type Locale = (typeof locales)[number]
const dictionaries: Record<Locale, typeof en> = { en, it, es }
export function getDictionary(locale: Locale): typeof en {
return dictionaries[locale] ?? en
}
2. El middleware de locale routing
El middleware lee URL, cabecera Accept-Language y cookie. Las primeras visitas se redirigen al mejor idioma; las visitas siguientes respetan la cookie. La cookie se setea cuando el usuario cambia idioma desde el language picker.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { locales, type Locale } from '@/config/i18n'
const DEFAULT_LOCALE: Locale = 'en'
function pickLocale(req: NextRequest): Locale {
const cookie = req.cookies.get('NEXT_LOCALE')?.value
if (cookie && locales.includes(cookie as Locale)) return cookie as Locale
const accept = req.headers.get('accept-language') ?? ''
for (const part of accept.split(',')) {
const tag = part.split(';')[0].trim().toLowerCase().split('-')[0]
if (locales.includes(tag as Locale)) return tag as Locale
}
return DEFAULT_LOCALE
}
export function middleware(req: NextRequest): NextResponse {
const { pathname } = req.nextUrl
if (locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))) {
return NextResponse.next()
}
const locale = pickLocale(req)
const url = req.nextUrl.clone()
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
return NextResponse.redirect(url)
}
export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] }
3. El helper de hreflang y canonical
El helper produce los tags <link> que cada página emite. El canonical apunta a la página en el idioma actual; el hreflang lista cada idioma soportado; x-default apunta a la versión inglesa.
// src/lib/seo/alternates.ts
import { locales, type Locale } from '@/config/i18n'
const BASE = process.env.PUBLIC_APP_URL ?? 'https://adamarant.com'
export function hreflang(pathByLocale: Record<Locale, string>, current: Locale) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = `${BASE}${pathByLocale[locale]}`
}
alternates['x-default'] = `${BASE}${pathByLocale.en}`
return {
canonical: alternates[current],
languages: alternates,
}
}
// app/[lang]/pricing/page.tsx
import type { Metadata } from 'next'
import { hreflang } from '@/lib/seo/alternates'
export async function generateMetadata({ params }: { params: Promise<{ lang: 'en' | 'it' | 'es' }> }): Promise<Metadata> {
const { lang } = await params
return {
alternates: hreflang(
{
en: '/en/pricing',
it: '/it/prezzi',
es: '/es/precios',
},
lang,
),
}
}
4. Los helpers de formato
Los helpers de formato envuelven Intl.*. Los format strings hardcoded los bloquea el lint; cada valor formateado pasa por el helper, que coge un idioma y (para la moneda) el país de facturación.
// src/lib/format.ts
import type { Locale } from '@/config/i18n'
const LOCALE_TAG: Record<Locale, string> = {
en: 'en-US',
it: 'it-IT',
es: 'es-ES',
}
export function formatDate(date: Date, locale: Locale): string {
return new Intl.DateTimeFormat(LOCALE_TAG[locale], { dateStyle: 'long' }).format(date)
}
export function formatNumber(value: number, locale: Locale): string {
return new Intl.NumberFormat(LOCALE_TAG[locale]).format(value)
}
export function formatCurrency(amountCents: number, currency: string, locale: Locale): string {
return new Intl.NumberFormat(LOCALE_TAG[locale], {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amountCents / 100)
}
5. El guardarraíl de traducción en CI
Un paso de CI hace diff de los diccionarios y hace fallar la build cuando un diccionario no-inglés tiene una clave que falta, tiene texto inglés donde debería haber traducción, o tiene una clave que ya no existe en inglés. Los diccionarios se mantienen sincronizados, y al equipo se le avisa antes del deploy, no después.
// scripts/i18n/check.ts
import { en } from '../../src/config/i18n/en'
import { it } from '../../src/config/i18n/it'
import { es } from '../../src/config/i18n/es'
type Dictionary = typeof en
function diffKeys(a: Dictionary, b: Dictionary, path = ''): string[] {
const issues: string[] = []
for (const key of Object.keys(a) as Array<keyof Dictionary>) {
const here = path ? `${path}.${String(key)}` : String(key)
if (!(key in b)) {
issues.push(`MISSING in target: ${here}`)
continue
}
const aValue = a[key] as unknown
const bValue = (b as Record<string, unknown>)[String(key)]
if (typeof aValue === 'object' && aValue && !Array.isArray(aValue)) {
issues.push(...diffKeys(aValue as Dictionary, bValue as Dictionary, here))
} else if (typeof aValue === 'string' && typeof bValue === 'string') {
if (aValue === bValue && a !== b) {
issues.push(`UNTRANSLATED in target (matches English): ${here}`)
}
}
}
for (const key of Object.keys(b) as Array<keyof Dictionary>) {
if (!(key in a)) {
const here = path ? `${path}.${String(key)}` : String(key)
issues.push(`STALE in target (no longer in English): ${here}`)
}
}
return issues
}
const issues = [...diffKeys(en, it as Dictionary, 'it'), ...diffKeys(en, es as Dictionary, 'es')]
if (issues.length > 0) {
console.error('i18n issues:\n' + issues.join('\n'))
process.exit(1)
}
console.log('i18n: all dictionaries in sync')
6. Lo que esto compone
Los diccionarios tipados mantienen cada string bajo un solo techo y hacen fallar la build cuando una desaparece. El middleware manda a cada visitante al idioma correcto y respeta su elección. El helper de hreflang le dice a Google que cada mercado existe y qué página pertenece a cuál. Los helpers de formato impiden que el símbolo del dólar aparezca en España. El guardarraíl de CI impide que el siguiente desarrollador entregue una página a medias.
El producto se lee nativo a un cliente español, a un cliente italiano, a un cliente francés que entre después. La landing italiana ranquea en Italia porque Google ve una página italiana de verdad, no la traducción de una página inglesa. La página de precios enseña euros en Madrid y dólares en San Francisco sin que nadie toque un switch. El equipo que pensaba que lanzar en un mercado nuevo era un trimestre de trabajo ahora lo piensa como un sprint de traducción y un envío de sitemap.