32blogby Studio Mitsu

Trampas de next-intl: lo que me atrapó al construir un sitio multilingüe

Trampas reales al implementar next-intl v4 en 32blog.com — configuración de middleware, rutas dinámicas, SEO y bugs sutiles difíciles de detectar.

by omitsu11 min read
Contenido

Las trampas más comunes de next-intl v4 son: matchers de middleware incorrectos, mezclar el Link de Next.js con el de next-intl, usar useTranslations en Server Components asíncronos, olvidar el locale en generateStaticParams, y configuración incompleta de hreflang. Cada una rompe tu sitio multilingüe silenciosamente, sin errores obvios.

Cuando agregué soporte para japonés, inglés y español a 32blog.com usando next-intl v4, me encontré con trampas que no estaban cubiertas en la documentación oficial.

Muchas eran del peor tipo — "parece que funciona pero en realidad está roto." Este artículo documenta cada trampa que encontré, con las soluciones.

Trampa 1: El patrón del matcher en el middleware está mal

Esto fue lo primero que hice mal. Un patrón de matcher incompleto en el middleware significa que algunas solicitudes se saltan la detección de locale — así que visitar / no redirige a /ja/.

El patrón roto común

typescript
// src/middleware.ts
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Esto parece razonable pero pasa archivos estáticos como .png y .svg por el middleware innecesariamente. Es más lento en desarrollo y puede causar casos extremos.

El patrón correcto

typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: [
    // Ruta raíz
    "/",
    // Rutas con prefijo de locale
    "/(ja|en|es)/:path*",
    // Rutas sin extensiones de archivo (excluye archivos estáticos)
    "/((?!api|_next|_vercel|.*\\..*).*)"],
};

La parte .*\\..* es la clave — excluye rutas que contienen un punto (extensión de archivo), lo que omite archivos estáticos. Este es el patrón recomendado en la documentación de middleware de next-intl.

Usar el Link de Next.js directamente rompe el comportamiento del prefijo de locale durante la navegación.

tsx
// ❌ Esto no preserva el prefijo del locale
import Link from "next/link";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      Leer artículo
    </Link>
  );
}

Si un usuario japonés está en /ja/ y hace clic en este enlace, aterriza en /nextjs/hydration-error-fix — el prefijo /ja/ desapareció.

tsx
// ✅ El Link de next-intl agrega automáticamente el prefijo del locale actual
import { Link } from "@/i18n/routing";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      Leer artículo
    </Link>
  );
}

Link de @/i18n/routing antepone el locale actual automáticamente. Escribir /nextjs/hydration-error-fix se convierte en /ja/nextjs/hydration-error-fix para usuarios japoneses. Consulta la documentación de navegación de next-intl para la API completa.

Lo mismo aplica para useRouter y redirect:

typescript
// ❌ Los hooks de Next.js pierden el locale
import { useRouter } from "next/navigation";
import { redirect } from "next/navigation";

// ✅ Usa las versiones de next-intl en su lugar
import { useRouter, redirect } from "@/i18n/routing";

Trampa 3: Usar useTranslations en un Server Component asíncrono

En next-intl v4, useTranslations funciona en Server Components — pero fuerza la página al renderizado dinámico. Si quieres que tus páginas se rendericen estáticamente, necesitas getTranslations.

tsx
// ⚠️ Funciona, pero fuerza renderizado dinámico
// Sin "use client" = Server Component
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("home");
  // Esta página nunca se renderizará estáticamente
  return <h1>{t("title")}</h1>;
}
tsx
// ✅ Los Server Components deberían usar getTranslations (soporta renderizado estático)
import { getTranslations } from "next-intl/server";

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // getTranslations requiere await
  const t = await getTranslations({ locale, namespace: "home" });

  return <h1>{t("title")}</h1>;
}

Referencia rápida (ver documentación de Server & Client Components):

Server ComponentClient Component
TraduccionesgetTranslations (requiere await)useTranslations
Locale actualgetLocaleuseLocale
Todos los mensajesgetMessagesuseMessages

Trampa 4: Falta el locale en generateStaticParams

En sitios multilingües, generateStaticParams debe incluir locale como parámetro.

Generar sin locale (los artículos del locale no predeterminado dan 404)

tsx
// app/[locale]/[category]/[slug]/page.tsx

// ❌ Solo genera para el locale predeterminado (ja)
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    // ¡Falta locale! Solo se genera el locale predeterminado
    category: post.category,
    slug: post.slug,
  }));
}

Generar todas las combinaciones locale × slug

tsx
// ✅ Generar rutas para todos los locales × todos los artículos
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"] as const;
  const paths = [];

  for (const locale of locales) {
    const posts = await getPostsByLocale(locale);
    for (const post of posts) {
      paths.push({
        locale,
        category: post.category,
        slug: post.slug,
      });
    }
  }

  return paths;
}

Si los artículos en inglés o español devuelven 404 después de agregarlos, esta es casi siempre la causa. La documentación de generateStaticParams de Next.js explica cómo interactúa con los segmentos dinámicos.

Trampa 5: Falta x-default en hreflang

Un error SEO común: omitir x-default de la configuración de hreflang.

tsx
// ❌ Sin x-default (Google no puede determinar el predeterminado)
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;

  return {
    alternates: {
      languages: {
        "ja": `https://32blog.com/ja/${category}/${slug}`,
        "en": `https://32blog.com/en/${category}/${slug}`,
        "es": `https://32blog.com/es/${category}/${slug}`,
        // ¡Falta x-default!
      },
    },
  };
}
tsx
// ✅ Incluir x-default para especificar el idioma de respaldo
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;
  const baseUrl = "https://32blog.com";

  return {
    alternates: {
      canonical: `${baseUrl}/${locale}/${category}/${slug}`,
      languages: {
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "es": `${baseUrl}/es/${category}/${slug}`,
        // A dónde enviar usuarios cuyo idioma no está soportado
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

x-default le dice a Google a dónde enviar usuarios cuyo idioma no coincide con ningún locale soportado (por ejemplo, visitantes franceses). Sin él, Google ve un sitio multilingüe pero no puede determinar el predeterminado, lo que puede afectar el posicionamiento. La especificación de hreflang de Google cubre esto en detalle.

Trampa 6: notFound no obtiene contexto de locale

Llamar a notFound() renderiza la página 404 — pero app/not-found.tsx está fuera de [locale], así que las traducciones de next-intl no funcionan ahí.

app/
├── not-found.tsx          ← Fuera de [locale] → traducciones no funcionarán
└── [locale]/
    └── not-found.tsx      ← Dentro de [locale] → traducciones funcionan
tsx
// ❌ app/not-found.tsx
// No puede usar traducciones de next-intl aquí
export default function NotFound() {
  return (
    <div>
      <h1>404 - Page Not Found</h1>
      {/* Los usuarios en inglés ven esto, pero también los japoneses */}
    </div>
  );
}
tsx
// ✅ app/[locale]/not-found.tsx
import { getTranslations } from "next-intl/server";

export default async function NotFound() {
  const t = await getTranslations("notFound");

  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
    </div>
  );
}

Nota: not-found.tsx es un Server Component, así que usa getTranslations en lugar de useTranslations. app/[locale]/not-found.tsx se usa cuando se llama a notFound() desde dentro del segmento [locale]. Los 404 que ocurren antes de que el middleware procese el locale (como rutas a nivel raíz) aún usan app/not-found.tsx. Mantén ambos archivos. Consulta la guía de not-found de next-intl para la configuración recomendada.

Trampa 7: Locale obsoleto en la memoización de Client Components

Después de cambiar de idioma, los Client Components pueden retener el locale antiguo.

tsx
// ❌ El valor memoizado no se actualiza después del cambio de locale
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [] // ❌ locale no está en deps — memo nunca recalcula
  );

  return <time>{formatted}</time>;
}
tsx
// ✅ Incluir locale en el array de dependencias
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [date, locale] // ✅ locale incluido
  );

  return <time>{formatted}</time>;
}

Cuando locale falta de las dependencias de useMemo o useCallback, cambiar de idioma muestra datos formateados obsoletos. Este es sutil — la página navega correctamente, pero la fecha o número formateado se queda en el formato del locale anterior. La documentación de useMemo de React explica cómo funcionan los arrays de dependencias.

FAQ

¿next-intl funciona con el Pages Router de Next.js?

next-intl v4 soporta tanto Pages Router como App Router. Sin embargo, la API difiere significativamente entre ellos. Este artículo cubre solo App Router. Si estás en Pages Router, consulta la documentación de Pages Router de next-intl.

¿Cómo depuro cuando el middleware no redirige al locale correcto?

Agrega console.log dentro de tu función de middleware para confirmar que se está llamando. Verifica que tu patrón de matcher no esté excluyendo las rutas que esperas redirigir. También puedes verificar el header de respuesta x-next-intl-locale — si falta, el middleware no procesó la solicitud.

¿Puedo usar next-intl con Turbopack?

Sí. next-intl v4 funciona con Turbopack (el bundler predeterminado en Next.js 16). No se necesita configuración especial. Si encuentras problemas, asegúrate de estar en la última versión de next-intl.

Usa la regla no-restricted-imports incorporada en tu configuración de ESLint:

json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "next/link",
        "message": "Usa Link de '@/i18n/routing' en su lugar."
      }, {
        "name": "next/navigation",
        "importNames": ["useRouter", "redirect"],
        "message": "Usa desde '@/i18n/routing' en su lugar."
      }]
    }]
  }
}

¿useTranslations lanza un error en Server Components?

No, no en next-intl v4. useTranslations funciona en Server Components, pero fuerza la página al renderizado dinámico. Para páginas renderizadas estáticamente, usa getTranslations de next-intl/server en su lugar. Consulta Server & Client Components en la documentación.

¿Cómo funciona la detección de locale para visitantes nuevos?

El middleware de next-intl detecta automáticamente el locale del header Accept-Language, cookies o tu valor predeterminado configurado. Puedes personalizar la estrategia de detección en la configuración de routing. En 32blog, configuramos japonés como predeterminado y dejamos que el middleware redirija según la preferencia de idioma del navegador.

¿Cuál es la diferencia entre next-intl y next-i18next?

next-i18next fue popular para Pages Router pero no ha adoptado completamente los patrones de App Router. next-intl fue construido para App Router desde el principio, soporta React Server Components nativamente y tiene mantenimiento activo (v4.8.3 a marzo de 2026). Para proyectos nuevos con App Router, next-intl es la opción recomendada.

Conclusión

Aquí tienes cada trampa resumida:

TrampaSíntomaSolución
Matcher incompletoLa redirección raíz no funcionaUsa el patrón correcto de tres partes
Link de Next.js en lugar del Link de next-intlEl prefijo de locale desaparece en la navegaciónUsa Link de @/i18n/routing
useTranslations en Server Component asíncronoLa página se fuerza a renderizado dinámicoUsa getTranslations (con await)
Sin locale en generateStaticParamsEl locale no predeterminado devuelve 404Genera combinaciones locale × slug
Falta x-defaultSEO de hreflang incompletoAgrega x-default a alternates.languages
not-found.tsx fuera de [locale]Página 404 no traducidaColoca not-found.tsx dentro de [locale]
locale falta de las dependencias de memoLocale obsoleto después del cambio de idiomaAgrega locale a las deps de useMemo/useCallback

next-intl es una gran biblioteca una vez configurada correctamente. La prevención más impactante es restringir el Link, useRouter y redirect nativos de Next.js mediante ESLint — detectar el error de importación al escribir en lugar de en tiempo de ejecución.

Artículos relacionados: