32blogby StudioMitsu

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.

8 min read
Contenido

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

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)/: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.

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}`}>
      Read article
    </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}`}>
      Read article
    </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.

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

next-intl usa funciones diferentes para Server y Client Components:

tsx
// ❌ useTranslations no funciona en Server Components
// Sin "use client" = Server Component
import { useTranslations } from "next-intl";

export default async function HomePage() {
  // Error: useTranslations no está soportado en Server Components
  const t = useTranslations("home");

  return <h1>{t("title")}</h1>;
}
tsx
// ✅ Los Server Components usan getTranslations (con await)
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:

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 en inglés 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 x slug

tsx
// ✅ Generar rutas para todos los locales × todos los artículos
export async function generateStaticParams() {
  const locales = ["ja", "en"] 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 devuelven 404 después de agregarlos, esta es casi siempre la causa.

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}`,
        // ¡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}`,
        // 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.

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.

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.

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 ComponentError en tiempo de ejecuciónUsa 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.