Cambiar tema

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

Easton editorial illustration: component assembly loom

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:

  1. App Router ya no admite la configuración i18n de Pages Router: este es el mayor problema. Si configuras el campo i18n en next.config.js, App Router no lo reconocerá en absoluto.

  2. 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 como cookies() y headers(), se informará un error.

  3. 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ísticasPages RouterApp Router
Método de configuraciónCampo i18n de next.config.jsmiddleware + enrutamiento dinámico [lang]
Estructura de enrutamientoGenerar automáticamente los prefijos /en/, /zh/Cree manualmente app/[lang]/page.tsx
Generación estáticaUtilice getStaticPathsUtilice generateStaticParams
Cargando traducciónFunción serverSideTranslationsLos 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í? **

  1. 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.
  2. 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.
  3. 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:

  1. as const: esta es la forma de escribir TypeScript, lo que garantiza que el tipo sea un tipo literal preciso, no una cadena [] amplia.
  2. 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.
  3. 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:

  1. Mecanismo de almacenamiento en caché: almacene en caché el archivo después de cargarlo por primera vez para evitar la lectura repetida de archivos.
  2. Manejo de errores: No fallará cuando no se pueda encontrar el archivo de traducción, simplemente advertirá y devolverá un objeto vacío.
  3. Sustitución de variable: admite el uso del marcador de posición {{nombre de variable}} en la traducción.
  4. 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:

  1. Estructura jerárquica: utilice objetos anidados para organizar las traducciones, no coloque todas las claves en el nivel superior.
  2. Marcador de posición variable: utilice el formato {{nombre de variable}} para facilitar el procesamiento unificado.
  3. Mantenga los nombres de las claves consistentes: los archivos de traducción para todos los idiomas deben tener la misma estructura de claves.
  4. 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:

  1. Compruebe si hay una preferencia de idioma guardada en la cookie (por ejemplo, el usuario seleccionó chino la última vez)
  2. De lo contrario, verifique el encabezado “Aceptar-Idioma” del navegador (el navegador enviará automáticamente el idioma del sistema del usuario)
  3. Según los resultados de la detección, redirija a https://example.com/zh-CN/blog o https://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ónTiempo de construcciónDescripción
Prerenderizado en 8 idiomas~24 minutosGeneración de páginas estáticas para todos los idiomas
Prerenderizado en 2 idiomas~6 minutosGenerado en primera visita en otros idiomas
Solo renderiza 1 idioma~3 minutosEntorno 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í:

  1. Solo se generan 2 idiomas × 10 artículos = 20 páginas al construir
  2. Cuando un usuario accede a una página que no está renderizada previamente, Next.js se generará y almacenará en caché en tiempo real.
  3. 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:

  1. ✅ Compruebe si generateStaticParams está definido en layout.tsx o page.tsx
  2. ✅ Asegúrese de que el nombre de la función esté escrito correctamente (no getStaticParams, no generateParams)
  3. ✅ Confirme que la función se exporte correctamente (debe ser “export async function”)
  4. ✅ 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:

  1. ✅ 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)
  2. ✅ Confirme que la sintaxis del archivo JSON sea correcta (se puede verificar con herramientas en línea)
  3. ✅ Verifique la configuración del alias de ruta de tsconfig.json:
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
  1. ✅ 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.ts y el directorio del archivo de traducción
  • [] Implementar la detección de idioma middleware.ts
  • [] Agregue generateStaticParams al diseño raíz
  • [] Configure next.config.js (si se requiere exportación estática, configure output: '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.readFile para 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/BibliotecaUsoÍndice de recomendacionesDescripción
siguiente-intlSolución completa i18n⭐⭐⭐⭐⭐Recomendación oficial, las funciones más completas, compatible con App Router
próximo internacionalBiblioteca ligera i18n⭐⭐⭐⭐Ligero, conciso y con seguridad de escritura
@formatjs/intlFormato internacional⭐⭐⭐⭐Fecha de procesamiento, número, moneda y otros formatos
typesafe-i18ntipo seguro traducción⭐⭐⭐⭐Generar automáticamente definiciones de tipos
i18siguienteAntigua 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:

  1. Funciones principales

    • Estructura multilingüe basada en enrutamiento dinámico [lang]
    • Utilice generateStaticParams para 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
  2. 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.
  3. 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

Conclusiones clave:

  • El i18n de App Router debe implementarse manualmente y no se puede configurar usando Pages Router
  • generateStaticParams debe 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() y headers()
  • 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»?
App Router exige generateStaticParams para la generación estática.

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?
Métodos:
• 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?
Causas frecuentes:
• 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?
Devuelve todas las combinaciones de idioma:

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?
No. La generación estática no puede usar:
• 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?
Métodos:
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?
SSG (generación de sitio estático):
• 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

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog