Saltar al contenido principal
Volver al blog

Cómo internacionalizar tu app Next.js 16 con next-intl

Ray MartínRay Martín
10 min de lectura
Cómo internacionalizar tu app Next.js 16 con next-intl

¿Por qué internacionalizar?

En un mundo cada vez más conectado, ofrecer tu aplicación en un solo idioma limita drásticamente tu alcance. La internacionalización (i18n) no es simplemente traducir textos: implica adaptar fechas, números, plurales, direccionalidad del texto y mucho más. Si estás construyendo un producto con Next.js 16, la librería next-intl es la solución más completa y con mejor integración en el ecosistema.

Algunas razones clave para internacionalizar tu aplicación:

  • Mayor alcance de mercado: Acceder a usuarios en diferentes regiones e idiomas multiplica tu audiencia potencial.
  • Mejor SEO: Los motores de búsqueda indexan contenido en múltiples idiomas, lo que incrementa tu visibilidad orgánica.
  • Experiencia de usuario: Los usuarios prefieren interactuar con aplicaciones en su idioma nativo, lo que aumenta la retención y conversión.
  • Requisito legal: En muchas regiones, ofrecer contenido en el idioma local es un requisito normativo.
  • Ventaja competitiva: Pocas aplicaciones invierten en una i18n de calidad, lo que te diferencia de la competencia.

Next.js 16 con App Router y React Server Components introduce nuevos patrones para manejar traducciones tanto en el servidor como en el cliente. next-intl se adapta perfectamente a esta arquitectura, permitiéndote usar traducciones en Server Components sin enviar JavaScript innecesario al navegador.

Instalación y configuración inicial

Comenzamos instalando next-intl en nuestro proyecto Next.js 16. Asegúrate de tener un proyecto con App Router ya configurado.

bash
npm install next-intl

A continuación, creamos el archivo de configuración principal de i18n en la raíz del proyecto. Este archivo define los idiomas soportados y la configuración de carga de mensajes.

typescript
// i18n.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./i18n/routing";

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // Validar que el locale solicitado está soportado
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`./messages/${locale}.json`)).default,
  };
});

También necesitamos definir la configuración de routing para los idiomas soportados:

typescript
// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";

export const routing = defineRouting({
  locales: ["es", "en"],
  defaultLocale: "es",
  localePrefix: "as-needed", // No muestra /es en URLs para el idioma por defecto
});

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

La opción localePrefix: "as-needed" es especialmente útil porque evita mostrar el prefijo del idioma por defecto en las URLs. Así, tu sitio en español se sirve desde / mientras que la versión en inglés usa /en/.

Finalmente, actualiza tu archivo next.config.ts para integrar el plugin de next-intl:

typescript
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

const nextConfig = {
  // tu configuración existente
};

export default withNextIntl(nextConfig);

Estructura de mensajes

Los mensajes de traducción se organizan en archivos JSON dentro del directorio /messages/. La clave de una buena estructura es usar namespaces para agrupar traducciones por contexto o sección de la aplicación.

json
// messages/es.json
{
  "hero_banner": {
    "title": "Construyo productos digitales que importan",
    "subtitle": "Desarrollo fullstack con enfoque en rendimiento y accesibilidad",
    "cta_primary": "Ver proyectos",
    "cta_secondary": "Contactar"
  },
  "services": {
    "title": "Servicios",
    "description": "Soluciones integrales de desarrollo web",
    "web_development": {
      "title": "Desarrollo Web",
      "description": "Aplicaciones modernas con Next.js, React y TypeScript"
    },
    "consulting": {
      "title": "Consultoría técnica",
      "description": "Asesoramiento en arquitectura, rendimiento y mejores prácticas"
    }
  },
  "contact": {
    "title": "Contacto",
    "form": {
      "name": "Nombre completo",
      "email": "Correo electrónico",
      "message": "Mensaje",
      "submit": "Enviar mensaje",
      "success": "Tu mensaje ha sido enviado correctamente",
      "error": "Ha ocurrido un error. Por favor, inténtalo de nuevo."
    },
    "validation": {
      "name_required": "El nombre es obligatorio",
      "email_invalid": "Introduce un correo electrónico válido",
      "message_min": "El mensaje debe tener al menos {min} caracteres"
    }
  },
  "footer": {
    "rights": "Todos los derechos reservados",
    "built_with": "Construido con {framework}"
  }
}
json
// messages/en.json
{
  "hero_banner": {
    "title": "I build digital products that matter",
    "subtitle": "Fullstack development focused on performance and accessibility",
    "cta_primary": "View projects",
    "cta_secondary": "Get in touch"
  },
  "services": {
    "title": "Services",
    "description": "Comprehensive web development solutions",
    "web_development": {
      "title": "Web Development",
      "description": "Modern applications with Next.js, React, and TypeScript"
    },
    "consulting": {
      "title": "Technical Consulting",
      "description": "Guidance on architecture, performance, and best practices"
    }
  },
  "contact": {
    "title": "Contact",
    "form": {
      "name": "Full name",
      "email": "Email address",
      "message": "Message",
      "submit": "Send message",
      "success": "Your message has been sent successfully",
      "error": "An error occurred. Please try again."
    },
    "validation": {
      "name_required": "Name is required",
      "email_invalid": "Enter a valid email address",
      "message_min": "Message must be at least {min} characters"
    }
  },
  "footer": {
    "rights": "All rights reserved",
    "built_with": "Built with {framework}"
  }
}

Algunas recomendaciones para la estructura de mensajes:

  • Usa namespaces descriptivos que coincidan con las secciones de tu aplicación.
  • Mantén las claves consistentes entre archivos de idioma.
  • Agrupa mensajes relacionados en objetos anidados para facilitar el mantenimiento.
  • Usa placeholders con llaves ({variable}) para valores dinámicos.
  • Evita duplicar textos: si un mismo texto se usa en múltiples secciones, crea un namespace common.

Configurar el middleware

El middleware es fundamental para la detección automática del idioma del usuario y el enrutamiento basado en locale. Next-intl proporciona una función createMiddleware que gestiona todo este proceso de forma transparente.

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

export default createMiddleware(routing);

export const config = {
  // Coincide con todas las rutas excepto archivos estáticos y API routes
  matcher: [
    "/",
    "/(es|en)/:path*",
    "/((?!api|_next|_vercel|.*\\..*).*)",
  ],
};

El middleware realiza las siguientes tareas automáticamente:

  • Detección de idioma: Analiza la cabecera Accept-Language del navegador para determinar el idioma preferido del usuario.
  • Redirección automática: Redirige a los usuarios al locale correcto basándose en sus preferencias.
  • Cookie de preferencia: Almacena la elección del usuario en una cookie para futuras visitas.
  • Reescritura de URLs: Gestiona las URLs internas para que el parámetro [locale] esté siempre disponible.

Si necesitas personalizar la detección del idioma o añadir lógica adicional, puedes envolver el middleware:

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

const intlMiddleware = createMiddleware(routing);

export default function middleware(request: NextRequest) {
  // Lógica personalizada antes de la detección de idioma
  const pathname = request.nextUrl.pathname;

  // Excluir rutas específicas del middleware de i18n
  if (pathname.startsWith("/api") || pathname.startsWith("/admin")) {
    return;
  }

  return intlMiddleware(request);
}

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)", "/"],
};

Routing con [locale]

El App Router de Next.js 16 utiliza segmentos dinámicos de ruta para gestionar los idiomas. El directorio app/[locale]/ contiene todas las páginas de tu aplicación, y el parámetro locale se pasa automáticamente como prop.

typescript
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import {
  getMessages,
  getTranslations,
  setRequestLocale,
} from "next-intl/server";
import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation";

// Generar rutas estáticas para todos los idiomas soportados
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

// Generar metadatos dinámicos basados en el idioma
export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "metadata" });

  return {
    title: t("title"),
    description: t("description"),
  };
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  // Validar el locale
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }

  // Habilitar renderizado estático
  setRequestLocale(locale);

  // Cargar todos los mensajes para el locale actual
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
typescript
// app/[locale]/page.tsx
import { setRequestLocale } from "next-intl/server";
import { getTranslations } from "next-intl/server";
import HeroBanner from "@/components/sections/HeroBanner";
import Services from "@/components/sections/Services";

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  setRequestLocale(locale);

  const t = await getTranslations({ locale, namespace: "hero_banner" });

  return (
    <main role="main">
      <HeroBanner />
      <Services />
    </main>
  );
}

Es importante llamar a setRequestLocale(locale) al inicio de cada página y layout cuando usas generación estática. Esto permite que next-intl determine el locale correcto durante el proceso de build.

Usar traducciones en componentes

Next-intl ofrece dos APIs principales para acceder a traducciones, dependiendo de si estás en un Server Component o un Client Component.

En Server Components

Usa la función asíncrona getTranslations para obtener traducciones en el servidor. Esto no envía JavaScript al cliente.

typescript
// components/sections/Services.tsx (Server Component)
import { getTranslations } from "next-intl/server";

export default async function Services() {
  const t = await getTranslations("services");

  return (
    <section id="services" aria-labelledby="services-title">
      <h2 id="services-title">{t("title")}</h2>
      <p>{t("description")}</p>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div>
          <h3>{t("web_development.title")}</h3>
          <p>{t("web_development.description")}</p>
        </div>
        <div>
          <h3>{t("consulting.title")}</h3>
          <p>{t("consulting.description")}</p>
        </div>
      </div>
    </section>
  );
}

En Client Components

Usa el hook useTranslations en componentes que necesitan interactividad. Recuerda que el componente debe estar envuelto por NextIntlClientProvider.

typescript
// components/common/ContactModal.tsx
"use client";

import { useTranslations } from "next-intl";
import { useState } from "react";

export default function ContactModal() {
  const t = useTranslations("contact");
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button
        onClick={() => setIsOpen(true)}
        aria-label={t("form.submit")}
        className="bg-primary text-white px-6 py-3 rounded-lg"
      >
        {t("title")}
      </button>

      {isOpen && (
        <div role="dialog" aria-modal="true" aria-label={t("title")}>
          <form>
            <label htmlFor="name">{t("form.name")}</label>
            <input id="name" type="text" />

            <label htmlFor="email">{t("form.email")}</label>
            <input id="email" type="email" />

            <label htmlFor="message">{t("form.message")}</label>
            <textarea id="message" />

            <button type="submit">{t("form.submit")}</button>
          </form>
        </div>
      )}
    </>
  );
}

La diferencia principal es que getTranslations es una función asíncrona para el servidor, mientras que useTranslations es un hook síncrono para el cliente. Siempre que sea posible, prefiere Server Components para evitar enviar las traducciones como JavaScript al navegador.

Texto rico y plurales

Next-intl soporta texto rico (rich text) que permite incrustar elementos HTML o componentes React dentro de las traducciones. Esto es especialmente útil para textos con formato, enlaces o énfasis.

json
// messages/es.json
{
  "about": {
    "bio": "Soy un <strong>desarrollador fullstack</strong> con más de {years} años de experiencia construyendo <link>productos digitales</link>.",
    "items_count": "Tienes {count, plural, =0 {ningún proyecto} one {un proyecto} other {# proyectos}} en tu portafolio."
  }
}
typescript
// Uso de texto rico con t.rich()
"use client";

import { useTranslations } from "next-intl";

export default function About() {
  const t = useTranslations("about");

  return (
    <section>
      <p>
        {t.rich("bio", {
          strong: (chunks) => <strong className="font-bold">{chunks}</strong>,
          link: (chunks) => (
            <a href="/projects" className="text-primary underline">
              {chunks}
            </a>
          ),
          years: 8,
        })}
      </p>

      <p>{t("items_count", { count: 12 })}</p>
    </section>
  );
}

La sintaxis de plurales sigue el estándar ICU MessageFormat, que es ampliamente adoptado en herramientas de i18n. Los casos disponibles son:

  • =0: Exactamente cero elementos
  • one: Un elemento (singular)
  • other: Cualquier otra cantidad (plural)
  • few y many: Para idiomas con reglas de plural complejas (ruso, árabe, etc.)

El carácter # dentro de la expresión plural se reemplaza automáticamente por el valor numérico.

SEO multilingüe

Para que los motores de búsqueda indexen correctamente las versiones en diferentes idiomas de tu sitio, es esencial configurar las etiquetas hreflang y generar metadatos dinámicos por locale.

typescript
// app/[locale]/layout.tsx - generateMetadata
import { getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { getPathname } from "@/i18n/routing";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "metadata" });

  const baseUrl = "https://raymartin.es";

  // Generar URLs alternativas para cada idioma
  const languages: Record<string, string> = {};
  for (const loc of routing.locales) {
    const prefix = loc === routing.defaultLocale ? "" : `/${loc}`;
    languages[loc] = `${baseUrl}${prefix}`;
  }

  return {
    title: {
      template: `%s | ${t("site_name")}`,
      default: t("title"),
    },
    description: t("description"),
    alternates: {
      canonical: languages[locale],
      languages,
    },
    openGraph: {
      title: t("title"),
      description: t("description"),
      locale: locale === "es" ? "es_ES" : "en_US",
      alternateLocale: locale === "es" ? "en_US" : "es_ES",
      url: languages[locale],
      siteName: t("site_name"),
      type: "website",
    },
    twitter: {
      card: "summary_large_image",
      title: t("title"),
      description: t("description"),
    },
  };
}

Además de los metadatos, considera estas prácticas para mejorar tu SEO multilingüe:

  • Usa la etiqueta lang en el elemento <html> (ya gestionada por el layout).
  • Genera un sitemap.xml que incluya todas las versiones lingüísticas de cada página.
  • Usa URLs semánticas y traducidas cuando sea posible (por ejemplo, /es/servicios y /en/services).
  • Asegúrate de que el contenido de cada idioma sea único y completo, no una traducción automática de baja calidad.
  • Configura Google Search Console para cada versión del idioma.
typescript
// app/sitemap.ts
import { routing } from "@/i18n/routing";
import { MetadataRoute } from "next";

const baseUrl = "https://raymartin.es";

const routes = ["/", "/services", "/projects", "/about", "/contact"];

export default function sitemap(): MetadataRoute.Sitemap {
  const entries: MetadataRoute.Sitemap = [];

  for (const route of routes) {
    for (const locale of routing.locales) {
      const prefix = locale === routing.defaultLocale ? "" : `/${locale}`;
      const url = `${baseUrl}${prefix}${route === "/" ? "" : route}`;

      const languages: Record<string, string> = {};
      for (const altLocale of routing.locales) {
        const altPrefix =
          altLocale === routing.defaultLocale ? "" : `/${altLocale}`;
        languages[altLocale] = `${baseUrl}${altPrefix}${route === "/" ? "" : route}`;
      }

      entries.push({
        url,
        lastModified: new Date(),
        alternates: { languages },
        changeFrequency: "weekly",
        priority: route === "/" ? 1.0 : 0.8,
      });
    }
  }

  return entries;
}

Buenas prácticas

Tras implementar i18n en múltiples proyectos con Next.js y next-intl, estas son las prácticas que marcan la diferencia entre una implementación mediocre y una excelente:

  1. Prefiere Server Components para traducciones estáticas: Usa getTranslations en lugar de useTranslations siempre que el componente no necesite interactividad. Esto reduce el JavaScript enviado al cliente.
  2. Organiza los namespaces por dominio de negocio: En lugar de un único archivo enorme, agrupa las traducciones por secciones funcionales (hero, services, contact, etc.).
  3. No hardcodees textos: Cualquier texto visible por el usuario debe estar en los archivos de mensajes, incluyendo atributos aria-label, textos de botones, y mensajes de error.
  4. Mantén las claves sincronizadas: Usa herramientas o scripts que verifiquen que todas las claves existen en todos los archivos de idioma. Una clave faltante puede provocar errores en producción.
  5. Usa TypeScript para seguridad de tipos: Next-intl puede generar tipos para tus mensajes, lo que previene errores por claves inexistentes en tiempo de compilación.
  6. Formatea fechas y números con las APIs de next-intl: Usa useFormatter o format.dateTime() en lugar de librerías externas para mantener la consistencia.
  7. Prueba ambos idiomas: No asumas que una traducción funciona porque la clave existe. Revisa visualmente cada idioma para detectar textos cortados, layout roto o traducciones incorrectas.
  8. Configura la detección de idioma correctamente: Asegúrate de que el middleware detecta el idioma del navegador y permite al usuario cambiarlo manualmente con persistencia.
  9. Implementa un selector de idioma accesible: El componente de cambio de idioma debe ser accesible por teclado, tener etiquetas ARIA correctas y persistir la preferencia del usuario.
  10. Documenta el proceso para traductores: Si trabajas con un equipo, documenta la estructura de archivos, convenciones de nombrado y el flujo de trabajo para añadir nuevas traducciones.
typescript
// Ejemplo: Selector de idioma accesible
"use client";

import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import { routing } from "@/i18n/routing";
import { useTransition } from "react";

const localeNames: Record<string, string> = {
  es: "Español",
  en: "English",
};

export default function LanguageSelector() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();

  function handleLocaleChange(newLocale: string) {
    startTransition(() => {
      router.replace({ pathname }, { locale: newLocale });
    });
  }

  return (
    <nav aria-label="Seleccionar idioma">
      <ul role="menubar" className="flex gap-2">
        {routing.locales.map((loc) => (
          <li key={loc} role="none">
            <button
              role="menuitem"
              onClick={() => handleLocaleChange(loc)}
              disabled={isPending}
              aria-current={locale === loc ? "true" : undefined}
              className={`px-3 py-1 rounded ${
                locale === loc
                  ? "bg-primary text-white"
                  : "bg-gray-100 hover:bg-gray-200"
              }`}
            >
              {localeNames[loc]}
            </button>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Siguiendo estas prácticas y la configuración descrita en esta guía, tendrás una aplicación Next.js 16 completamente internacionalizada, con un rendimiento óptimo gracias a la integración con Server Components, y con una base sólida para escalar a más idiomas en el futuro.

Compartir:

Artículos relacionados