Internacionalización de Next.js y generación estática: práctica de sitios web multilingües de SSG

La primera vez que intenté realizar una generación estática en varios idiomas en el proyecto Next.js App Router, realmente me encontré con muchos obstáculos. Es posible que haya tenido una experiencia similar: configuró de acuerdo con la documentación, pero se informa un error tan pronto como compila; o finalmente has construido exitosamente, sólo para descubrir que tomó 15 minutos generar todas las páginas…
Primero, déjame compartir algunos escenarios típicos que he encontrado para ver si te resulta familiar.
¿También has encontrado estos problemas?
Escenario 1: Error al construir
Recuerdo una vez que ejecuté npm run build con entusiasmo, pero la terminal me arrojó directamente un error:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.
En ese momento, estaba completamente confundido y pensé: “¿Qué diablos? ¡Configuré claramente i18n en next.config.js!” Más tarde descubrí que los métodos de internacionalización de App Router y Pages Router son completamente diferentes y la configuración anterior no funcionó en absoluto.
Escenario 2: el tiempo de construcción es demasiado largo
En otra ocasión, mi proyecto admitía 6 idiomas, con aproximadamente 50 páginas por idioma. El resultado se construye de una sola vez:
✓ Generating static pages (152/152) - 15m 32s
¡15 minutos! Has leído bien. Cada vez que cambio algo pequeño, tengo que esperar mucho tiempo y la experiencia de desarrollo simplemente se arruina. Estaba pensando: “Si esto se pone en un entorno de producción, CI/CD no tendrá que esperar hasta el infinito”.
Escenario 3: la actualización de la traducción no surte efecto
Lo más enloquecedor es esto: actualicé el archivo de traducción zh-CN.json, lo reconstruí y lo volví a implementar, ¡y la traducción anterior todavía se mostraba en el sitio web! Se debe borrar la memoria caché del navegador para ver contenido nuevo. Esto es un desastre en un entorno de producción. Todo lo que los usuarios ven es contenido caducado.
¿Cuál es la raíz del problema?
Posteriormente, dediqué mucho tiempo a investigar para comprender la esencia de estos problemas:
-
App Router ya no admite la configuración i18n de Pages Router: este es el mayor problema. Si configuras el campo
i18nennext.config.js, App Router no lo reconocerá en absoluto. -
Conflicto entre exportación estática y representación dinámica: cuando configuras
output: 'export', Next.js requiere que todas las páginas se determinen en el momento de la compilación. Si usas API dinámicas comocookies()yheaders(), se informará un error. -
Mecanismo de almacenamiento en caché de archivos de traducción: Next.js almacenará en caché los archivos JSON importados. La traducción se actualizó durante el desarrollo, pero el caché no ha caducado, por lo que no se puede ver el contenido más reciente.
Si tú también has encontrado estos problemas, entonces este artículo es para ti. A continuación, te enseño paso a paso cómo implementar correctamente la generación estática en varios idiomas de Next.js App Router y evitar estos errores.
Comprenda el nuevo paradigma i18n de App Router
Antes de empezar a escribir código, creo que es necesario comprender las ideas de internacionalización de App Router. Esto es realmente diferente de Pages Router.
Pages Router vs App Router: dos soluciones muy diferentes
Hice una tabla comparativa para que puedas sentir intuitivamente la diferencia:
| Características | Pages Router | App Router |
|---|---|---|
| Método de configuración | Campo i18n de next.config.js | middleware + enrutamiento dinámico [lang] |
| Estructura de enrutamiento | Generar automáticamente los prefijos /en/, /zh/ | Cree manualmente app/[lang]/page.tsx |
| Generación estática | Utilice getStaticPaths | Utilice generateStaticParams |
| Cargando traducción | Función serverSideTranslations | Los componentes del lado del servidor “importan” directamente JSON |
¿Viste eso? Casi todo ha cambiado. Cuando me encontré con esto por primera vez, realmente sentí que “aprendí Next.js falso”.
¿Qué es exactamente generateStaticParams?
Este es uno de los conceptos centrales de App Router. En pocas palabras, su función es decirle a Next.js: “Para qué parámetros necesito generar una página estática”.
Por ejemplo:
// app/[lang]/layout.tsx
export async function generateStaticParams() {
//Devuelve todos los parámetros de idioma que deben renderizarse previamente
return [
{ lang: 'en' },
{ lang: 'zh' },
{ lang: 'ja' }
]
}
Next.js ejecutará esta función durante la compilación, obtendrá la lista de parámetros devuelta y luego generará un archivo HTML estático para cada combinación de parámetros. El resultado final es:
out/
├── en/
│ └── index.html
├── zh/
│ └── index.html
└── ja/
└── index.html
Punto clave: Esta función debe definirse en layout.tsx o page.tsx, y el nombre de la función debe coincidir exactamente (no getStaticParams, no generateParams, debe ser generateStaticParams).
¿Cómo cargar archivos de traducción?
En la era de Pages Router, usábamos la función serverSideTranslations de la biblioteca next-i18next. Pero en App Router, puedes importar archivos de traducción directamente al componente del servidor:
// Los componentes del lado del servidor pueden hacer esto directamente
import enTranslations from '@/i18n/locales/en/common.json'
import zhTranslations from '@/i18n/locales/zh-CN/common.json'
const translations = {
'en': enTranslations,
'zh-CN': zhTranslations,
}
export default function Page({ params }: { params: { lang: string } }) {
const t = translations[params.lang]
return <h1>{t.title}</h1>
}
Pero hay un problema con esto: todas las traducciones de idiomas se empaquetarán en paquetes, lo que hará que el archivo se haga más grande. Entonces, en proyectos reales, generalmente escribimos una función de carga:
// i18n/utils.ts
export async function loadTranslations(locale: string, namespaces: string[]) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
try {
const translation = await import(`@/i18n/locales/${locale}/${ns}.json`)
translations[ns] = translation.default
} catch (error) {
console.warn(`Translation file not found: ${locale}/${ns}`)
translations[ns] = {}
}
}
return translations
}
Esto permite la carga bajo demanda, cargando solo los espacios de nombres de traducción necesarios para la página actual.
Combate práctico: cree un proyecto SSG en varios idiomas desde cero
Bien, ya terminé con la teoría, ahora comencemos a codificar. Lo guiaré de principio a fin para crear un sitio estático completo en varios idiomas.
Paso 1: Diseñar la estructura del proyecto
Primero, necesitamos establecer una estructura de directorios clara. Esta es la estructura que uso en proyectos reales y funciona bien en mi propia prueba:
app/
├── [lang]/ # Enrutamiento dinámico de idiomas (núcleo)
│ ├── layout.tsx # Diseño raíz, que contiene generateStaticParams
│ ├── page.tsx # Inicio
│ ├── about/
│ │ └── page.tsx # página About
│ └── blog/
│ ├── page.tsx # lista del blog
│ └── [slug]/
│ └── page.tsx # detalle del blog (enrutamiento dinámico anidado)
├── i18n/
│ ├── locales/ # Directorio de archivos de traducción
│ │ ├── en/
│ │ │ ├── common.json # traducción pública
│ │ │ ├── home.json # traducciones de home
│ │ │ └── blog.json # traducciones de blog
│ │ ├── zh-CN/
│ │ │ ├── common.json
│ │ │ ├── home.json
│ │ │ └── blog.json
│ │ └── ja/
│ │ ├── common.json
│ │ ├── home.json
│ │ └── blog.json
│ ├── config.ts # archivo de configuración i18n
│ └── utils.ts # Función de herramienta de traducción
└── middleware.ts # Detección y redirección de idioma
**¿Por qué está diseñado así? **
- Carpeta
[lang]: este es el núcleo del enrutamiento dinámico. Next.js pasará los parámetros de idioma en la URL a los componentes de la página. - Divida las traducciones por espacio de nombres: para evitar un archivo de traducción que sea demasiado grande, sepárelo por función de página y cárguelo cuando lo solicite.
- Configuración centralizada
config.ts: todas las configuraciones relacionadas con el idioma se colocan aquí para facilitar el mantenimiento.
Paso 2: Configurar los archivos principales de i18n
Primero escribamos el archivo de configuración, que es la base de todo el sistema:
// i18n/config.ts
export const i18nConfig = {
//Lista de idiomas soportados
locales: ['en', 'zh-CN', 'ja'],
//Idioma predeterminado
defaultLocale: 'en',
//Estrategia de prefijo de ruta
// 'siempre': todos los idiomas tienen el prefijo /en/, /zh-CN/
// 'según sea necesario': el idioma predeterminado no tiene prefijo, otros idiomas tienen prefijo
localePrefix: 'always',
// [Importante] Pre-renderizar solo el idioma principal (optimizar el tiempo de compilación)
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // producción: solo prerender en y zh-CN
: ['en'], // desarrollo: solo idioma por defecto
} as const
// Tipo de exportación para uso mediante verificación de tipo TypeScript
export type Locale = (typeof i18nConfig)['locales'][number]
// Espacio de nombres de traducción (para división de código)
export const namespaces = ['common', 'home', 'about', 'blog'] as const
export type Namespace = (typeof namespaces)[number]
Explicación de puntos clave:
as const: esta es la forma de escribir TypeScript, lo que garantiza que el tipo sea un tipo literal preciso, no unacadena []amplia.localesToPrerender: ¡Esto es muy importante! Si admite 10 idiomas pero solo renderiza previamente los 2 idiomas principales, los tiempos de compilación se pueden reducir en un 80 %. Se pueden generar otros idiomas mediante ISR (regeneración estática incremental) o bajo demanda.- Espacio de nombres: divida el archivo de traducción en varios JSON para evitar descargar un archivo de traducción enorme por primera vez.
Paso 3: Implementar la herramienta de carga de traducción
Este es un cargador de traducciones simple pero funcional:
// i18n/utils.ts
import type { Locale, Namespace } from './config'
// Almacenamiento en caché del archivo de traducción (para evitar lecturas repetidas)
const translationsCache = new Map<string, any>()
/**
* Cargue el archivo de traducción en el idioma especificado
*
* Código de idioma local @param, como 'en', 'zh-CN'
* @param namespaces traducción de matriz de espacios de nombres, como ['common', 'home']
* @returns objeto de traducción { común: {...}, inicio: {...} }
*/
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
const translations: Record<string, any> = {}
for (const namespace of namespaces) {
const cacheKey = `${locale}-${namespace}`
// Verifica el caché para evitar cargas repetidas
if (!translationsCache.has(cacheKey)) {
try {
// Importar dinámicamente archivos de traducción
const translation = await import(
`@/i18n/locales/${locale}/${namespace}.json`
)
translationsCache.set(cacheKey, translation.default)
} catch (error) {
console.warn(`⚠️ Translation file not found: ${locale}/${namespace}.json`)
translationsCache.set(cacheKey, {})
}
}
translations[namespace] = translationsCache.get(cacheKey)
}
return translations
}
/**
* Crear funciones de traducción con seguridad de escritura
*
* Uso:
* const t = createTranslator(translations)
* t('common.nav.home')
* t('home.welcome', { nombre: 'John' }) // Admite sustitución de variables
*/
export function createTranslator(translations: any) {
return (key: string, params?: Record<string, string>) => {
const keys = key.split('.')
let value = translations
//Accede a propiedades anidadas capa por capa
for (const k of keys) {
value = value?.[k]
}
//Devuelve la clave cuando no se encuentra ninguna traducción (facilita la depuración)
if (!value) {
console.warn(`⚠️ Translation missing: ${key}`)
return key
}
// Admite sustitución de variables: reemplaza {{name}} con el valor real
if (params) {
return Object.entries(params).reduce(
(str, [key, val]) => str.replace(`{{${key}}}`, val),
value
)
}
return value
}
}
Aspectos destacados de esta herramienta:
- Mecanismo de almacenamiento en caché: almacene en caché el archivo después de cargarlo por primera vez para evitar la lectura repetida de archivos.
- Manejo de errores: No fallará cuando no se pueda encontrar el archivo de traducción, simplemente advertirá y devolverá un objeto vacío.
- Sustitución de variable: admite el uso del marcador de posición
{{nombre de variable}}en la traducción. - Compatible con tipos: la verificación de claves de traducción con tipos seguros se puede lograr con TypeScript.
Paso 4: Crear el diseño raíz (el más crítico)
Este es el archivo principal de todo el sistema multilingüe:
// app/[lang]/layout.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
/**
* [Núcleo] Generar parámetros estáticos para todos los idiomas
*
* Esta función se ejecuta durante la construcción y Next.js generará la página estática correspondiente en función del valor de retorno.
*
*NOTA IMPORTANTE:
* 1. El nombre de la función debe ser generateStaticParams (no puede estar mal escrito)
* 2. Debe definirse en layout.tsx o page.tsx
* 3. El nombre del parámetro devuelto debe coincidir con el nombre de la carpeta de enrutamiento ([lang] → lang)
*/
export async function generateStaticParams() {
console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)
return i18nConfig.localesToPrerender.map((locale) => ({
lang: locale, // ⚠️ debe ser 'lang', no 'locale'
}))
}
/**
*Componente de diseño raíz
*
* Este componente envolverá todas las páginas y se utilizará para establecer configuraciones globales.
*/
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
// Cargar traducciones comunes (navegación, pie de página, etc.)
const translations = await loadTranslations(params.lang as Locale, ['common'])
return (
<html
lang={params.lang}
// Si es árabe, establecer diseño de derecha a izquierda
dir={params.lang === 'ar' ? 'rtl' : 'ltr'}
>
<head>
{/* Aquí se pueden añadir metaetiquetas globales */}
</head>
<body>
{/* Aquí puedes colocar navegación, pie de página y otros componentes globales */}
{children}
</body>
</html>
)
}
/**
* Generar metadatos (SEO)
*
* Esta función se utiliza para generar etiquetas como <título> y <meta> de la página.
*/
export async function generateMetadata({ params }: { params: { lang: string } }) {
return {
//Establecer metaetiquetas relacionadas con el idioma
alternates: {
canonical: `https://example.com/${params.lang}`,
languages: {
'en': 'https://example.com/en',
'zh-CN': 'https://example.com/zh-CN',
'ja': 'https://example.com/ja',
},
},
// etiquetas Open Graph (para compartir en redes sociales)
openGraph: {
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}
Aquí hay algunos errores fáciles:
⚠️ Pozo 1: los nombres de los parámetros deben coincidir
// ❌ Error: el nombre del parámetro es local, pero la carpeta de enrutamiento es [lang]
export async function generateStaticParams() {
return [{ locale: 'en' }] // esto provoca error
}
// ✅ Correcto: el nombre del parámetro es consistente con el nombre de la carpeta
export async function generateStaticParams() {
return [{ lang: 'en' }] // debe ser lang
}
⚠️ Pit 2: No se puede utilizar la API dinámica
// ❌ Error: uso de cookies en páginas generadas estáticamente
export default async function Layout({ children }) {
const locale = cookies().get('NEXT_LOCALE') // esto hace fallar el build
return <html lang={locale}>{children}</html>
}
// ✅ Correcto: usar parámetros de ruta
export default async function Layout({ children, params }) {
return <html lang={params.lang}>{children}</html>
}
Paso 5: crear un archivo de traducción
La estructura del archivo de traducción también es importante. Este es el formato que recomiendo:
// i18n/locales/zh-CN/common.json
{
"nav": {
"home": "Inicio",
"about": "Acerca de",
"blog": "Blog",
"contact": "Contacto"
},
"footer": {
"copyright": "© {{year}} Todos los derechos reservados",
"privacy": "Política de privacidad",
"terms": "Términos de servicio"
},
"actions": {
"readMore": "Leer más",
"backToTop": "Volver arriba",
"share": "Compartir",
"edit": "Editar"
},
"messages": {
"loading": "Cargando...",
"error": "Ha ocurrido un error",
"success": "Operación exitosa",
"noResults": "No se encontraron resultados"
}
}
// i18n/locales/zh-CN/blog.json
{
"title": "Artículos del blog",
"publishedAt": "Publicado el",
"author": "Autor",
"tags": "etiquetas",
"relatedPosts": "Artículos relacionados",
"readingTime": "Tiempo de lectura: {{minutes}} min",
"shareOn": "Compartir en {{platform}}"
}
Mejores prácticas para traducir documentos:
- Estructura jerárquica: utilice objetos anidados para organizar las traducciones, no coloque todas las claves en el nivel superior.
- Marcador de posición variable: utilice el formato
{{nombre de variable}}para facilitar el procesamiento unificado. - Mantenga los nombres de las claves consistentes: los archivos de traducción para todos los idiomas deben tener la misma estructura de claves.
- Agregar comentarios: agregue comentarios junto a traducciones complejas para explicar escenarios de uso.
Paso 6: Procesar el enrutamiento dinámico anidado
Si su proyecto tiene un blog o una página de detalles del producto, debe manejar el enrutamiento dinámico anidado. Éste es uno de los mayores escollos en los que me he topado jamás.
// app/[lang]/blog/[slug]/page.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations, createTranslator } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
// Supongamos que tiene estas funciones auxiliares (debe implementarlas usted mismo en proyectos reales)
async function getBlogSlugs(): Promise<string[]> {
// Obtener todos los Artículos del blog desde el sistema de archivos o CMS
return ['getting-started', 'advanced-tips', 'performance-guide']
}
async function getBlogPost(slug: string, locale: Locale) {
// Obtener contenido de Artículos del blog en un idioma específico
// ...
}
/**
* [Clave] generar parámetros estáticos de enrutamiento anidado
*
* Necesidad de generar todas las combinaciones de idioma × artículo.
* Por ejemplo: es/empezando-, zh-CN/empezando-, es/consejos-avanzados...
*/
export async function generateStaticParams() {
const startTime = Date.now()
console.log('📝 Generating blog post params...')
// Obtener el slug de todos los artículos (solo se requiere una solicitud)
const slugs = await getBlogSlugs()
// Usa flatMap para generar combinaciones de todos los idiomas y artículos
const params = i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({
lang: locale,
slug: slug,
}))
)
const duration = Date.now() - startTime
console.log(`✅ Generated ${params.length} blog post params in ${duration}ms`)
return params
}
/**
* Componente de página de artículos del blog.
*/
export default async function BlogPost({
params,
}: {
params: { lang: string; slug: string }
}) {
//Cargar traducción y contenido del artículo.
const [translations, post] = await Promise.all([
loadTranslations(params.lang as Locale, ['common', 'blog']),
getBlogPost(params.slug, params.lang as Locale),
])
const t = createTranslator(translations)
return (
<article className="prose">
<h1>{post.title}</h1>
<p className="text-gray-600">
{t('blog.publishedAt')}: {new Date(post.date).toLocaleDateString(params.lang)}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
Puntos clave para la optimización del rendimiento:
Aquí es fácil cometer un error. Es posible que desees obtener los datos por separado para cada idioma:
// ❌ Enfoque incorrecto: solicitudes múltiples, construcción lenta
export async function generateStaticParams() {
const results = []
for (const locale of i18nConfig.localesToPrerender) {
// Solicitar la base de datos o CMS una vez por idioma, ¡demasiado lento!
const slugs = await getBlogSlugs(locale)
results.push(...slugs.map(slug => ({ lang: locale, slug })))
}
return results
}
El enfoque correcto es solicitar los datos solo una vez y luego usar flatMap para generar la combinación:
// ✅ Enfoque correcto: una solicitud, generación rápida
export async function generateStaticParams() {
//Solo solicita datos una vez
const slugs = await getBlogSlugs()
// Usa flatMap para generar combinaciones de todos los idiomas × artículos
return i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({ lang: locale, slug }))
)
}
En mi proyecto, esta optimización redujo el tiempo de construcción de 18 minutos a 6 minutos. ¡El efecto es muy obvio!
Paso 7: implementar la detección de idioma del middleware
La función del Middleware es detectar automáticamente la preferencia de idioma del usuario y redirigir a la versión de idioma correspondiente.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from './i18n/config'
/**
*Middleware
*
* Esta función se ejecutará antes de cada solicitud y se utiliza para:
* 1. Detectar la preferencia de idioma del usuario.
* 2. Redirigir a la ruta de idioma correspondiente
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
//Comprueba si la ruta ya contiene el prefijo de idioma
const pathnameHasLocale = i18nConfig.locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
// Si ya existe un prefijo de idioma, lo dejamos pasar directamente
if (pathnameHasLocale) return
//Obtener el idioma preferido del usuario
const locale = getLocale(request) ?? i18nConfig.defaultLocale
// Redirigir a la ruta con prefijo de idioma
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
/**
* Función de detección de idioma
*
*Prioridad:
* 1. Preferencia de idioma guardada en Cookie
* 2. Encabezado de solicitud de idioma aceptado
* 3. Devuelve nulo, usa el idioma predeterminado
*/
function getLocale(request: NextRequest): string | null {
//Prioridad 1: comprobar cookies
const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
if (localeCookie && i18nConfig.locales.includes(localeCookie as any)) {
return localeCookie
}
//Prioridad 2: comprobar el encabezado de solicitud Aceptar-Idioma
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
// Aceptar formato de idioma: zh-CN,zh;q=0.9,en;q=0.8
const preferred = acceptLanguage.split(',')[0].split('-')[0]
const match = i18nConfig.locales.find(locale =>
locale.toLowerCase().startsWith(preferred.toLowerCase())
)
if (match) return match
}
// No se encontró ningún idioma coincidente, devuelve nulo
return null
}
/**
* Configuración de middleware
*
* el comparador define qué rutas deben ejecutar el middleware
*/
export const config = {
// Coincide con todas las rutas excepto:
// - Rutas API que comienzan con /api
// - /_next/static archivos estáticos
// - /_siguiente/imagen imagen
// - /favicon.ico y otros recursos estáticos
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
Escenarios de uso de middleware:
Suponiendo que el usuario accede a https://example.com/blog directamente, el Middleware:
- Compruebe si hay una preferencia de idioma guardada en la cookie (por ejemplo, el usuario seleccionó chino la última vez)
- De lo contrario, verifique el encabezado “Aceptar-Idioma” del navegador (el navegador enviará automáticamente el idioma del sistema del usuario)
- Según los resultados de la detección, redirija a
https://example.com/zh-CN/blogohttps://example.com/en/blog
De esta forma se consigue la detección automática del idioma y la experiencia del usuario es mejor.
Paso 8: Crear el componente de cambio de idioma
Finalmente, necesitamos un selector de idiomas que permita al usuario cambiar de idioma manualmente:
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { i18nConfig } from '@/i18n/config'
import type { Locale } from '@/i18n/config'
// Asignación de nombre para mostrar del idioma
const localeNames: Record<Locale, string> = {
'en': 'English',
'zh-CN': '简体中文',
'ja': '日本語',
}
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname()
const router = useRouter()
const handleLocaleChange = (newLocale: Locale) => {
//Guardar preferencia de idioma en la cookie
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`
// Reemplazar el prefijo de idioma en la ruta
// Por ejemplo: /zh-CN/blog → /en/blog
const newPathname = pathname.replace(`/${currentLocale}`, `/${newLocale}`)
// Saltar a la nueva versión de idioma
router.push(newPathname)
}
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
className="px-4 py-2 border rounded-lg"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
)
}
Usar en la barra de navegación:
// components/Navigation.tsx
import { LanguageSwitcher } from './LanguageSwitcher'
export function Navigation({ lang }: { lang: string }) {
return (
<nav className="flex items-center justify-between p-4">
<div className="flex gap-4">
<a href={`/${lang}/`}>Inicio</a>
<a href={`/${lang}/about`}>Acerca de</a>
<a href={`/${lang}/blog`}>Blog</a>
</div>
<LanguageSwitcher currentLocale={lang} />
</nav>
)
}
Optimización del rendimiento: haz volar la velocidad de construcción
Las funciones básicas ya están implementadas, pero si su sitio web admite varios idiomas, el tiempo de construcción puede ser muy largo. Permítanme compartir algunos consejos prácticos de optimización.
Optimización 1: renderizado previo selectivo
Esta es la optimización más efectiva. Si admite 10 idiomas, pero el tráfico real se concentra en 2 o 3 idiomas principales, presente solo los idiomas principales:
// i18n/config.ts
export const i18nConfig = {
//Todos los idiomas soportados
locales: ['en', 'zh-CN', 'ja', 'ko', 'de', 'fr', 'es', 'pt'],
defaultLocale: 'en',
// [Clave] Solo renderiza previamente el idioma principal
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // producción: solo en y zh-CN
: ['en'], // desarrollo: solo idioma por defecto (más rápido)
}
Comparación de efectos:
| Configuración | Tiempo de construcción | Descripción |
|---|---|---|
| Prerenderizado en 8 idiomas | ~24 minutos | Generación de páginas estáticas para todos los idiomas |
| Prerenderizado en 2 idiomas | ~6 minutos | Generado en primera visita en otros idiomas |
| Solo renderiza 1 idioma | ~3 minutos | Entorno de desarrollo recomendado |
¡Ahorró el 75% del tiempo de construcción!
Optimización 2: utilizar regeneración estática incremental (ISR)
Para idiomas menos importantes o páginas visitadas con poca frecuencia, ISR se puede utilizar para generar bajo demanda:
// app/[lang]/blog/[slug]/page.tsx
// Habilita ISR y vuelve a verificar después de 1 hora
export const revalidate = 3600
export async function generateStaticParams() {
const slugs = await getBlogSlugs()
// Solo renderiza previamente artículos populares en los principales idiomas
const topSlugs = slugs.slice(0, 10) // solo prerender las 10 primeras
return i18nConfig.localesToPrerender.flatMap((locale) =>
topSlugs.map((slug) => ({ lang: locale, slug }))
)
}
// 【Importante】permitir generar dinámicamente páginas no prerenderizadas
export const dynamicParams = true
Después de configurar así:
- Solo se generan 2 idiomas × 10 artículos = 20 páginas al construir
- Cuando un usuario accede a una página que no está renderizada previamente, Next.js se generará y almacenará en caché en tiempo real.
- Actualizar automáticamente después de almacenar en caché durante 1 hora
Optimización 3: obtener datos en paralelo
En generateStaticParams, si necesitas obtener varios tipos de datos, debes procesarlos en paralelo:
// ❌ Error: Adquisición en serie (lenta)
export async function generateStaticParams() {
const posts = await getBlogPosts() // espera 2 s
const categories = await getCategories() // espera 1 s
// 3 segundos en total
}
// ✅ Correcto: adquisición paralela (rápida)
export async function generateStaticParams() {
const [posts, categories] = await Promise.all([
getBlogPosts(), // en paralelo
getCategories(), // en paralelo
])
// 2 segundos en total (toma el más largo)
}
En mi proyecto, esta optimización redujo el tiempo de adquisición de datos en un 40%.
Optimización 4: Resuelva el problema del caché de traducción
Lo más molesto durante el desarrollo es que el archivo de traducción se actualiza, pero la página no se actualiza. Esto se debe a que Next.js almacena en caché los archivos JSON importados.
Solución: deshabilite el almacenamiento en caché en el entorno de desarrollo
// i18n/utils.ts
import fs from 'fs/promises'
import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
// Entorno de desarrollo: vuelva a leer el archivo cada vez
if (isDev) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
const filePath = path.join(
process.cwd(),
'i18n',
'locales',
locale,
`${ns}.json`
)
try {
const content = await fs.readFile(filePath, 'utf-8')
translations[ns] = JSON.parse(content)
} catch (error) {
console.warn(`Translation file not found: ${filePath}`)
translations[ns] = {}
}
}
return translations
}
//Entorno de producción: usar caché
return loadTranslationsWithCache(locale, namespaces)
}
De esta manera, puede ver el contenido más reciente actualizando la página después de actualizar el archivo de traducción durante el desarrollo.
Guía de solución de problemas de preguntas frecuentes
En el desarrollo real, también puede encontrar otros problemas. Aquí he recopilado algunos de los más comunes, así como mis soluciones.
Problema 1: Error “generateStaticParams no encontrado” al compilar
mensaje de error:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.
Pasos para la solución de problemas:
- ✅ Compruebe si
generateStaticParamsestá definido enlayout.tsxopage.tsx - ✅ Asegúrese de que el nombre de la función esté escrito correctamente (no
getStaticParams, nogenerateParams) - ✅ Confirme que la función se exporte correctamente (debe ser “export async function”)
- ✅ Compruebe si el nombre del parámetro coincide con el nombre de la carpeta de enrutamiento
// ❌ Ejemplo de error
export async function getStaticParams() { // nombre de función incorrecto
return [{ locale: 'en' }] // nombre de parámetro incorrecto
}
// ✅ Ejemplo correcto
export async function generateStaticParams() {
return [{ lang: 'en' }] // el parámetro debe coincidir con [lang]
}
Pregunta 2: Se detectó renderizado dinámico
mensaje de error:
Error: Route /[lang]/about couldn't be rendered statically
because it used `headers` or `cookies`.
Causa: Las API dinámicas (headers(), cookies(), searchParams) se utilizan en páginas generadas estáticamente.
Solución:
// ❌ Error: uso de cookies en componentes del lado del servidor
export default async function Page() {
const locale = cookies().get('NEXT_LOCALE') // fuerza render dinámico
return <div>...</div>
}
// ✅ Opción 1: Proceso en middleware
// middleware.ts
export function middleware(request: NextRequest) {
const locale = request.cookies.get('NEXT_LOCALE')
// Lógica de procesamiento...
}
// ✅ Opción 2: Usar componentes del cliente
'use client'
export function LanguageSwitcher() {
const [locale, setLocale] = useState(() => {
//Leer cookie en el lado del cliente
return getCookie('NEXT_LOCALE')
})
// ...
}
Pregunta 3: Archivo de traducción no encontrado
mensaje de error:
Error: Cannot find module './locales/en/common.json'
Lista de verificación:
- ✅ Compruebe si la ruta del archivo es correcta (tenga en cuenta las mayúsculas y minúsculas, Linux distingue entre mayúsculas y minúsculas)
- ✅ Confirme que la sintaxis del archivo JSON sea correcta (se puede verificar con herramientas en línea)
- ✅ Verifique la configuración del alias de ruta de
tsconfig.json:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
- ✅ Confirma que los archivos de traducción estén incluidos correctamente en la compilación:
// next.config.js
module.exports = {
//Asegúrate de que el archivo JSON esté incluido
webpack: (config) => {
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
Problema 4: los parámetros de enrutamiento se pierden después de cambiar de idioma
Fenómenos: Después de cambiar de /zh-CN/blog/my-post al inglés, salta a /en/ en lugar de /en/blog/my-post.
Causa: El selector de idiomas no conservó los parámetros de enrutamiento correctamente.
Solución:
// ❌ Error: ruta codificada
<Link href="/about">About</Link>
// ✅ Opción 1: empalmar manualmente los parámetros del idioma
<Link href={`/${params.lang}/about`}>About</Link>
// ✅ Opción 2: encapsular un componente de enlace inteligente
// components/LocalizedLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export function LocalizedLink({
href,
children,
...props
}: {
href: string
children: React.ReactNode
[key: string]: any
}) {
const pathname = usePathname()
//Extrae el idioma de la ruta actual
const locale = pathname.split('/')[1]
// Agregar automáticamente el prefijo de idioma
const localizedHref = `/${locale}${href}`
return (
<Link href={localizedHref} {...props}>
{children}
</Link>
)
}
Problema 5: etiquetas SEO faltantes o incorrectas
Problema: Las etiquetas SEO (hreflang, canonical) de las páginas multiidioma están mal configuradas, afectando la inclusión en los motores de búsqueda.
Solución: Configurar correctamente en generateMetadata de cada página:
// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { lang: string; slug: string }
}) {
const baseUrl = 'https://example.com'
return {
// Título y descripción de la página
title: 'My Blog Post',
description: 'This is a blog post',
// URL canónica (enlace canónico)
alternates: {
canonical: `${baseUrl}/${params.lang}/blog/${params.slug}`,
// hreflang etiquetas (informa a los motores de búsqueda sobre versiones en otros idiomas)
languages: {
'en': `${baseUrl}/en/blog/${params.slug}`,
'zh-CN': `${baseUrl}/zh-CN/blog/${params.slug}`,
'ja': `${baseUrl}/ja/blog/${params.slug}`,
'x-default': `${baseUrl}/en/blog/${params.slug}`, // idioma por defecto
},
},
// etiquetas Open Graph (para compartir en redes sociales)
openGraph: {
title: 'My Blog Post',
description: 'This is a blog post',
url: `${baseUrl}/${params.lang}/blog/${params.slug}`,
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}
Resumen de mejores prácticas
Después de tanta práctica, he compilado una lista de mejores prácticas que se pueden implementar directamente.
Lista de inicialización del proyecto
Antes de comenzar el desarrollo, asegúrese de completar estas configuraciones:
- [] Determinar la lista de idiomas admitidos y el idioma predeterminado
- [] Crear estructura de directorio
app/[lang] - [] Configurar
i18n/config.tsy el directorio del archivo de traducción - [] Implementar la detección de idioma
middleware.ts - [] Agregue
generateStaticParamsal diseño raíz - [] Configure
next.config.js(si se requiere exportación estática, configureoutput: 'export')
Sugerencias de etapa de desarrollo
- [] El entorno de desarrollo solo prerenderiza el idioma predeterminado (
localesToPrerender: ['en']) - [] Utilice TypeScript para garantizar la seguridad de tipo de las claves de traducción
- Dividir el espacio de nombres de traducción según módulos funcionales (común, inicio, blog…)
- [] Deshabilitar el almacenamiento en caché de traducción en el entorno de desarrollo (use
fs.readFilepara leer en tiempo real) - [] Agregar registro de advertencia para traducciones faltantes (para facilitar el descubrimiento de problemas)
Lista de verificación de implementación de producción
- [] Representación previa selectiva de los principales idiomas (optimiza los tiempos de compilación)
- [] Configurar la política ISR (lenguaje secundario generado a pedido, configurar
revalidar) - [] Utilice la recuperación de datos paralela (
Promise.all) - [] Configurar etiquetas hreflang y canónicas correctas
- [] Establecer la estrategia de almacenamiento en caché de CDN (considere rutas en varios idiomas)
- [] Supervisar el número de visitas y el tiempo de compilación de cada versión de idioma.
next.config.js Ejemplo de configuración completo
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Exportación estática (si es necesario)
output: 'export',
//Configuración de optimización de imagen
images: {
unoptimized: true, // necesario para export estático
},
// variables de entorno
env: {
BUILD_TIME: new Date().toISOString(),
},
// ID de compilación personalizada (para invalidación de caché)
generateBuildId: async () => {
return `build-${Date.now()}`
},
// configuración del paquete web
webpack: (config, { isServer }) => {
//Asegúrate de que el archivo JSON se procese correctamente
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
module.exports = nextConfig
Herramientas y bibliotecas recomendadas
Si no desea implementarlo desde cero, considere usar estas bibliotecas ya preparadas:
| Herramientas/Biblioteca | Uso | Índice de recomendaciones | Descripción |
|---|---|---|---|
| siguiente-intl | Solución completa i18n | ⭐⭐⭐⭐⭐ | Recomendación oficial, las funciones más completas, compatible con App Router |
| próximo internacional | Biblioteca ligera i18n | ⭐⭐⭐⭐ | Ligero, conciso y con seguridad de escritura |
| @formatjs/intl | Formato internacional | ⭐⭐⭐⭐ | Fecha de procesamiento, número, moneda y otros formatos |
| typesafe-i18n | tipo seguro traducción | ⭐⭐⭐⭐ | Generar automáticamente definiciones de tipos |
| i18siguiente | Antigua biblioteca i18n | ⭐⭐⭐ | Potente pero necesita adaptarse a App Router |
Personalmente recomiendo next-intl, que está especialmente diseñado para Next.js App Router. Funciona de inmediato y no requiere tanta configuración por su cuenta. Pero si desea comprender en profundidad los principios de implementación de i18n o necesita un alto grado de personalización, la implementación manual (como este artículo) también es una buena opción.
Resumir
Para revisar, hemos implementado una solución completa de generación estática en varios idiomas Next.js App Router, que incluye:
-
Funciones principales
- Estructura multilingüe basada en enrutamiento dinámico
[lang] - Utilice
generateStaticParamspara generar páginas estáticas - Sistema de archivos de traducción por espacio de nombres.
- Detección y redirección automática de idioma de middleware
- Estructura multilingüe basada en enrutamiento dinámico
-
Optimización del rendimiento
- Representación previa selectiva de los principales idiomas (reduce el tiempo de construcción en un 75 %)
- Utilice ISR para generar idiomas secundarios a pedido
- Adquisición de datos en paralelo
- Deshabilitar el almacenamiento en caché en el entorno de desarrollo.
-
Resolución de problemas
- Error de configuración
generateStaticParams - La API dinámica provoca un error de compilación.
- Problema de almacenamiento en caché del archivo de traducción
- Ruta de cambio de idioma perdida
- Configuración de etiquetas SEO
- Error de configuración
Conclusiones clave:
- El i18n de App Router debe implementarse manualmente y no se puede configurar usando Pages Router
generateStaticParamsdebe definirse en el diseño o la página y los nombres de los parámetros deben coincidir- Las páginas generadas estáticamente no pueden utilizar API dinámicas como
cookies()yheaders() - Utilice el renderizado previo selectivo y el ISR de forma adecuada para evitar tiempos de compilación prolongados.
Si también está utilizando Next.js App Router para crear un sitio web en varios idiomas, espero que este artículo pueda ayudarle a evitar algunos errores. De hecho, la internacionalización en sí no es complicada. La clave es comprender el mecanismo de construcción de Next.js y luego configurarlo de acuerdo con sus reglas.
Finalmente, si cree que la implementación manual es demasiado problemática, recuerde probar la biblioteca next-intl, que puede ahorrarle muchos problemas.
FAQ
¿Por qué falla la compilación con «missing generateStaticParams»?
Solución:
• Define generateStaticParams en el layout o la página
• Devuelve todas las combinaciones de idioma
• Los nombres de los parámetros deben coincidir con la estructura de la ruta
Ejemplo:
export async function generateStaticParams() {
return locales.map(locale => ({ locale }))
}
¿Cómo reduzco el tiempo de compilación de un sitio multilingüe?
• Usa prerenderizado selectivo (solo las páginas importantes)
• Usa ISR para el contenido que cambia con frecuencia
• Cachea las traducciones
• Paraleliza el proceso de compilación
• Reduce el número de páginas por idioma
Ejemplo: prerenderiza la página de inicio en todos los idiomas y usa ISR para las entradas del blog.
¿Por qué no surten efecto las actualizaciones de traducción?
• La caché de compilación no se limpió
• Caché del navegador
• Caché del CDN
• Caché de la generación estática
Soluciones:
• Limpia la caché de compilación
• Usa cache busting
• Implementa ISR con revalidación
• Revisa la configuración de caché del CDN
¿Cómo uso generateStaticParams para rutas multilingües?
Ejemplo:
export async function generateStaticParams() {
const locales = ['en', 'zh']
const posts = await getPosts()
return locales.flatMap(locale =>
posts.map(post => ({ locale, slug: post.slug }))
)
}
Esto genera todas las combinaciones: /en/post-1, /zh/post-1, etc.
¿Puedo usar APIs dinámicas en la generación estática?
• cookies()
• headers()
• APIs dinámicas
Alternativas:
• Usa Server Components para los datos dinámicos
• Usa ISR en lugar de SSG
• Mueve la lógica dinámica a Client Components
Para i18n, usa middleware para detectar el idioma, no cookies en páginas estáticas.
¿Cómo implemento el prerenderizado selectivo?
1) Genera estáticamente solo las páginas importantes
2) Usa ISR para el resto
3) Usa renderizado dinámico para las páginas específicas de cada usuario
Ejemplo:
• Página de inicio: SSG (todos los idiomas)
• Entradas del blog: ISR (revalidate: 3600)
• Panel del usuario: renderizado dinámico
Así reduces el tiempo de compilación manteniendo el rendimiento.
¿Cuál es la diferencia entre SSG e ISR para i18n?
• Genera todas las páginas en tiempo de compilación
• Máximo rendimiento
• Pero requiere recompilar para actualizar
ISR (regeneración estática incremental):
• Pregenera, pero puede revalidar
• Buen equilibrio entre rendimiento y frescura
• Mejor para contenido que cambia con frecuencia
Para sitios multilingües, usa SSG para el contenido estable e ISR para el dinámico.
25 min de lectura · Publicado el: 25 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Guía completa de internacionalización en Next.js: mejores prácticas con next-intl
Explicación profunda de la internacionalización con App Router en Next.js: configuración completa de next-intl, diseño de rutas multilingües, mejores prácticas para gestionar archivos de traducción y ejemplos de código listos para usar
Parte 15 de 51
Siguiente
SEO multilingüe en Next.js: guía completa para que los buscadores indexen cada idioma correctamente
Más del 60 % de los sitios multilingües tienen errores de configuración SEO. Esta guía explica hreflang, sitemaps multilingües y la elección de estrategia de URL para evitar trampas comunes y lograr el posicionamiento correcto en cada idioma.
Parte 17 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario