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
// 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
// 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.
Trampa 2: Mezclar el Link de next-intl con el Link de Next.js
Usar el Link de Next.js directamente rompe el comportamiento del prefijo de locale durante la navegación.
Problema: usar el Link de Next.js directamente
// ❌ 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ó.
Solución: usa el Link de next-intl
// ✅ 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:
// ❌ 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:
// ❌ 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>;
}
// ✅ 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 Component | Client Component | |
|---|---|---|
| Traducciones | getTranslations (requiere await) | useTranslations |
| Locale actual | getLocale | useLocale |
| Todos los mensajes | getMessages | useMessages |
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)
// 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
// ✅ 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.
// ❌ 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!
},
},
};
}
// ✅ 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
// ❌ 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>
);
}
// ✅ 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.
// ❌ 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>;
}
// ✅ 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:
| Trampa | Síntoma | Solución |
|---|---|---|
| Matcher incompleto | La redirección raíz no funciona | Usa el patrón correcto de tres partes |
| Link de Next.js en lugar del Link de next-intl | El prefijo de locale desaparece en la navegación | Usa Link de @/i18n/routing |
useTranslations en Server Component | Error en tiempo de ejecución | Usa getTranslations (con await) |
Sin locale en generateStaticParams | El locale no predeterminado devuelve 404 | Genera combinaciones locale × slug |
Falta x-default | SEO de hreflang incompleto | Agrega x-default a alternates.languages |
not-found.tsx fuera de [locale] | Página 404 no traducida | Coloca not-found.tsx dentro de [locale] |
locale falta de las dependencias de memo | Locale obsoleto después del cambio de idioma | Agrega 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.