32blog.com soporta tanto japonés como inglés. Las URLs siguen un patrón de prefijo: /ja/nextjs/slug para japonés y /en/nextjs/slug para inglés.
La biblioteca que hace esto posible es next-intl v4. Este artículo recorre la configuración completa — configuración de rutas, uso de traducciones a nivel de componente, selector de idioma y cambio de idioma en artículos MDX — todo lo que realmente uso en 32blog.com.
¿Qué es next-intl?
Next.js soporta internacionalización de forma nativa mediante la opción i18n en next.config.js — pero solo en Pages Router. App Router eliminó esa opción. Para agregar i18n a App Router, tienes que construir tu propio enrutamiento o usar una biblioteca como next-intl.
next-intl es una biblioteca de i18n diseñada para App Router con estas características clave:
- Enrutamiento de locale basado en prefijo de URL (
/ja/...,/en/...) - Hooks de traducción que funcionan tanto en Server como en Client Components
- Soporte TypeScript (claves de traducción verificadas por tipos)
- Utilidades SEO (helpers de hreflang)
Instalación y estructura de archivos
npm install next-intl
Esta es la estructura de archivos que uso en 32blog.com:
src/
├── app/
│ ├── [locale]/ # Prefijo de locale
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── [category]/
│ │ └── [slug]/
│ │ └── page.tsx
│ └── api/
├── i18n/
│ ├── routing.ts # Configuración de rutas
│ └── request.ts # Configuración de request
├── messages/
│ ├── ja.json # Traducciones en japonés
│ └── en.json # Traducciones en inglés
└── middleware.ts # Detección de locale y redirecciones
Configuración de rutas
Comienza con src/i18n/routing.ts para definir los locales soportados y el predeterminado.
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ja", "en"],
defaultLocale: "ja",
// "always" significa que /ja/... y /en/... ambos tienen prefijos explícitos
localePrefix: "always",
});
Luego, crea middleware.ts. Esto intercepta las solicitudes, detecta el locale y redirige a la URL correcta.
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
// Aplicar middleware a estas rutas (excluir archivos estáticos y APIs internas)
matcher: [
"/",
"/(ja|en)/:path*",
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
Configuración de request
src/i18n/request.ts define cómo los Server Components cargan las traducciones para la solicitud actual.
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// Obtener locale de la URL
let locale = await requestLocale;
// Recurrir al locale predeterminado si se detecta un locale no soportado
if (!locale || !routing.locales.includes(locale as "ja" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
Crear archivos de traducción
Define el texto de traducción en messages/ja.json:
{
"nav": {
"home": "ホーム",
"about": "このブログについて",
"articles": "記事一覧"
},
"home": {
"hero": {
"title": "ググって最後にたどり着くページ",
"subtitle": "エラー解決・技術Tips・実装例を実体験ベースで書いています"
},
"latestArticles": "新着記事",
"allArticles": "すべての記事を見る"
},
"article": {
"publishedAt": "公開日",
"updatedAt": "更新日",
"readingTime": "{minutes}分で読めます",
"tableOfContents": "目次",
"relatedArticles": "関連記事"
}
}
Y en messages/en.json:
{
"nav": {
"home": "Home",
"about": "About",
"articles": "Articles"
},
"home": {
"hero": {
"title": "The last page you land on after Googling",
"subtitle": "Error fixes, tech tips, and real-world implementations based on actual experience"
},
"latestArticles": "Latest Articles",
"allArticles": "View all articles"
},
"article": {
"publishedAt": "Published",
"updatedAt": "Updated",
"readingTime": "{minutes} min read",
"tableOfContents": "Table of Contents",
"relatedArticles": "Related Articles"
}
}
Integración en el layout
Configura el provider de next-intl en app/[locale]/layout.tsx:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Devolver 404 para locales no soportados
if (!routing.locales.includes(locale as "ja" | "en")) {
notFound();
}
// Obtener mensajes de traducción desde Server Component
const messages = await getMessages();
return (
<html lang={locale}>
<body>
{/* El Provider permite que los Client Components accedan a las traducciones */}
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Uso de traducciones en Server Components
// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";
// Los Server Components usan getTranslations
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "home" });
return (
<main>
<section>
<h1>{t("hero.title")}</h1>
<p>{t("hero.subtitle")}</p>
</section>
</main>
);
}
Uso de traducciones en Client Components
// components/NavMenu.tsx
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
export function NavMenu() {
// Los Client Components usan useTranslations
const t = useTranslations("nav");
return (
<nav>
{/* El Link de next-intl agrega automáticamente el prefijo del locale actual */}
<Link href="/">{t("home")}</Link>
<Link href="/about">{t("about")}</Link>
</nav>
);
}
Usar el componente Link de next-intl significa que los prefijos de locale se agregan automáticamente. Escribir /about enlazará a /ja/about para usuarios en japonés y /en/about para usuarios en inglés — sin código adicional.
Construir el selector de idioma
Aquí está el selector de idioma que permite a los usuarios alternar entre idiomas:
// components/LocaleSwitcher.tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: string) => {
// Permanecer en la misma ruta pero cambiar el locale
router.replace(pathname, { locale: newLocale });
};
return (
<div>
<button
onClick={() => switchLocale("ja")}
aria-current={locale === "ja" ? "true" : undefined}
>
日本語
</button>
<button
onClick={() => switchLocale("en")}
aria-current={locale === "en" ? "true" : undefined}
>
English
</button>
</div>
);
}
Pasa una opción de locale a router.replace y next-intl maneja la transformación de la ruta. Si un usuario está leyendo /ja/nextjs/hydration-error-fix y hace clic en "English," llegará a /en/nextjs/hydration-error-fix.
Integración con TypeScript para claves tipadas
next-intl v4 soporta claves de traducción verificadas por TypeScript. Los errores tipográficos en nombres de claves se convierten en errores de compilación.
// types/global.d.ts
import en from "../messages/en.json";
type Messages = typeof en;
declare global {
interface IntlMessages extends Messages {}
}
Con esto en su lugar:
// ❌ La clave no existe — error de TypeScript
const title = t("nav.hme"); // Error: Argument of type '"hme"' is not assignable...
// ✅ Clave correcta — sin error
const title = t("nav.home"); // OK
SEO: Configurar hreflang
Los sitios multilingües necesitan etiquetas hreflang para que Google pueda asociar correctamente las versiones de idioma de la misma página. next-intl facilita esto mediante los metadatos de Next.js.
// app/[locale]/[category]/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; category: string; slug: string }>;
}): Promise<Metadata> {
const { locale, category, slug } = await params;
const post = await getPost(category, slug, locale);
const baseUrl = "https://32blog.com";
return {
title: post.title,
description: post.description,
alternates: {
canonical: `${baseUrl}/${locale}/${category}/${slug}`,
languages: {
// etiquetas hreflang
"ja": `${baseUrl}/ja/${category}/${slug}`,
"en": `${baseUrl}/en/${category}/${slug}`,
"x-default": `${baseUrl}/ja/${category}/${slug}`,
},
},
};
}
Esto genera automáticamente etiquetas <link rel="alternate" hreflang="ja" href="..."> en el <head> de cada página. Google puede entonces entender correctamente que las dos versiones son traducciones del mismo contenido, no páginas duplicadas.
Conclusión
Aquí tienes un resumen de los archivos clave en la configuración de next-intl v4:
| Archivo | Función |
|---|---|
i18n/routing.ts | Definir locales soportados y locale predeterminado |
middleware.ts | Detectar locale de las solicitudes y redirigir |
i18n/request.ts | Configurar cómo los Server Components cargan mensajes |
messages/ja.json | Cadenas de traducción en japonés |
app/[locale]/layout.tsx | Configurar NextIntlClientProvider |
Lo principal que confunde a la gente: usa getTranslations en Server Components y useTranslations en Client Components. De la misma manera, usa el Link de next-intl en lugar del Link incorporado de Next.js — de lo contrario los prefijos de locale no se agregarán automáticamente.
32blog.com ejecuta esta configuración en todas las páginas sirviendo miles de páginas vistas. La versión App Router de next-intl ha sido sólida y estable.