Cómo Implementé Internacionalización en Next.js 15: Guía Práctica desde Mi Experiencia
Hola comunidad 👋! Como desarrollador full stack, siempre busco optimizar la experiencia de usuario en mis proyectos. Hoy quiero compartirles cómo implementé la internacionalización en mi propio portafolio web usando Next.js 15.
Cuando decidí hacer mi sitio multilingüe, investigué varias opciones pero quería una solución ligera, eficiente y que aprovechara al máximo las nuevas capacidades del App Router. El resultado fue tan satisfactorio que quiero guiarte paso a paso para que tu también puedas hacerlo.
¿Por qué la internacionalización es crucial hoy?
Según estudios recientes de organizaciones especializadas en experiencia de usuario y comercio global:
Estas cifras me mostraron claramente que la internacionalización no es un lujo opcional, sino una necesidad estratégica para cualquier desarrollador que quiera crear productos con alcance global.
Configuración inicial: Minimalista pero poderosa
Solo necesitamos dos dependencias ligeras:
npm install @formatjs/intl-localematcher negotiator
npm install -D @types/negotiator # Solo para TypeScript
Mi estructura de carpetas recomendada:
src/
├── actions/
│ ├── translate.ts
├── app/
│ ├── [lang]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── not-found.tsx
│ │ ├── [...not-found]/
│ │ │ └── page.tsx
│ │ └── otherPage/
│ │ └── page.tsx
├── locales/
│ ├── en.json
│ ├── es.json
├── utils/
│ └── i18n.ts
└── middleware.ts
Implementación Paso a Paso: Lo que realmente funciona
1. Detección inteligente de idioma
Creé un utilitario que detecta el idioma del usuario de forma elegante usando Negotiator para leer el header "Accept-Language":
// src/utils/i18n.ts
import Negotiator from "negotiator"
import { match } from "@formatjs/intl-localematcher"
const supportedLocales = ["es", "en"]
const defaultLocale = "es"
const getLocale = (headers: { "accept-language": string }): string => {
const languages = new Negotiator({ headers }).languages()
return match(languages, supportedLocales, defaultLocale)
}
const hasPathnameLocale = (pathname: string): boolean => {
return supportedLocales.some(
(locale) =>
pathname.includes(`/${locale}/`) || pathname.endsWith(`/${locale}/`)
)
}
export { supportedLocales, defaultLocale, getLocale, hasPathnameLocale }
2. Middleware que hace magia
El middleware es el núcleo de toda la lógica. Lo afiné para que fuera lo más eficiente posible, cumpliendo su rol de interceptar cada petición y asignar el idioma correcto cuando hace falta. También añadí condiciones específicas para que no interfiera en recursos estáticos ni en rutas que no representan páginas reales. Por supuesto, estas exclusiones pueden ajustarse según las necesidades particulares de tu proyecto:
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server"
import { getLocale, hasPathnameLocale } from "@/utils/i18n"
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (
pathname.startsWith("/_next/") ||
pathname.startsWith("/api/") ||
pathname.startsWith("/images/") ||
pathname.startsWith("/documents/") ||
pathname === "/favicon.ico" ||
pathname.endsWith(".png")
) {
return NextResponse.next()
}
const hasLocal = hasPathnameLocale(pathname)
if (hasLocal) return
const locale = getLocale({
"accept-language": req.headers.get("Accept-Language") || "",
})
req.nextUrl.pathname = `${locale}${pathname}`
const response = NextResponse.redirect(req.nextUrl)
response.headers.set(
"Link",
`<${req.nextUrl.origin}/${locale}>; rel="canonical"`
)
return response
}
3. Traducciones organizadas
Para mantener claridad y escalabilidad, estructuro las traducciones en archivos JSON modularizados por componente, siguiendo el enfoque de diseño atómico. Cada “molécula” tiene su propio bloque de traducción, lo que facilita la mantenibilidad y evita duplicaciones innecesarias.
// src/locales/es.json
{
"hero": {
"greeting": "¡Hola!, Soy",
"specialization": "y me especializo en crear interfaces modernas y aplicaciones web...",
"download_cv": "Descargar CV",
"aria_label_send_email": "Enviar un correo a jorgereyes@jurgenkings.com"
},
"about": {
"title": "Soy",
"paragraph_1": "Bajo mi alias profesional Jurgen Kings, desarrollo soluciones Full Stack centradas en el stack MERN...",
"paragraph_2": "Actualmente amplío mi expertise hacia el desarrollo móvil con React Native..."
},
"footer": {
"title": "¡Creemos Algo Increíble Juntos!",
"all_rights_reserved": "Todos los derechos reservados."
}
}
💡 Tip profesional: Uso translate.i18next.com para traducciones automáticas y consistentes.
4. Server Actions para máximo rendimiento
Implementé una solución server-side optimizada que recupera las traducciones de cada componente de manera separada para mayor organización y rendimiento:
// src/actions/translate.ts
"use server"
const dictionaries: Record Promise>> = {
es: () => import("@/locales/es.json").then((module) => module.default),
en: () => import("@/locales/en.json").then((module) => module.default),
}
async function getTranslations(locale: string, component?: string) {
const dictionary = await dictionaries[locale]()
return component ? dictionary[component] || {} : dictionary
}
export default getTranslations
Optimizaciones que marcan la diferencia
1. SEO internacional impecable
// src/app/[lang]/layout|page.tsx
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise {
const baseUrl = process.env.BASE_URL || "https://jurgenkings.com"
const resolvedParams = await params
const { lang } = resolvedParams
const metaTranslations = await getTranslations(lang, "meta")
return {
metadataBase: new URL(baseUrl),
title: "Jurgen Kings",
description: metaTranslations.description,
keywords: metaTranslations.keywords,
twitter: {
card: "summary_large_image",
},
alternates: {
canonical: `${baseUrl}/${lang}`,
languages: {
"es": `${baseUrl}/es`,
"en": `${baseUrl}/en`,
}
}
}
}
2. Selector de idioma con animaciones suaves
Diseñé un componente completamente funcional utilizando Framer Motion, pensado para ofrecer transiciones fluidas y una experiencia de usuario más agradable. Puedes integrarlo directamente en tus proyectos y adaptarlo fácilmente a tus necesidades multilingües.
// src/components/LanguageSwitcher.tsx
"use client"
import { useState } from "react"
import { usePathname, useRouter } from "next/navigation"
import Image from "next/image"
import { motion, AnimatePresence } from "motion/react"
import { FaChevronDown } from "react-icons/fa6"
const languages = [
{ id: 1, name: "English", code: "en", icon: "/images/flag-gb.svg", alt: "English flag" },
{ id: 2, name: "Español", code: "es", icon: "/images/flag-spain.svg", alt: "Spanish flag" },
]
interface LanguageSwitcherProps {
currentLang: string
}
function LanguageSwitcher({ currentLang }: LanguageSwitcherProps): React.JSX.Element {
const router = useRouter()
const url = usePathname()
const [isOpen, setIsOpen] = useState(false)
const [selectedLanguage, setSelectedLanguage] = useState(currentLang === "en" ? "English" : "Español")
const toggleDropdown = () => setIsOpen(!isOpen)
const handleChangeLanguage = (newLang: string) => {
if (newLang === currentLang) return
setIsOpen(false)
setSelectedLanguage(newLang === "en" ? "English" : "Español")
const segments = url.split("/").filter(Boolean)
const lastSegment = segments.at(-1)
const basePath = `/${newLang}`
if (segments.includes("projects") || segments.includes("proyectos")) {
const projectPath = newLang === "es" ? "proyectos" : "projects"
router.push(`${basePath}/${projectPath}/${lastSegment}`)
return
}
if (segments.includes("blog")) {
const translatedSlug = slugByLang?.[newLang] || lastSegment
router.push(`${basePath}/blog/${translatedSlug}`)
return
}
router.push(basePath)
}
const selected = languages.find(lang => lang.name === selectedLanguage) || languages[0]
return (
{selectedLanguage}
{isOpen && (
{languages.map((language) => (
handleChangeLanguage(language.code)}
>
{language.name}
))}
)}
)
}
export default LanguageSwitcher
3. Manejo de rutas no válidas (404)
Para capturar cualquier ruta inválida dentro de un idioma, utilizo un archivo especial [...not-found]/page.tsx. Este archivo actúa como un catch-all y redirige directamente al sistema de error de Next:
// src/app/[lang]/[...not-found]/page.tsx
import { notFound } from "next/navigation"
const catchAll = () => {
return notFound()
}
export default catchAll
Este enfoque garantiza que cualquier URL mal escrita dentro de un idioma (por ejemplo, /es/xyz) sea redirigida correctamente al not-found.tsx correspondiente.
Ahora bien, el archivo not-found.tsx debe estar ubicado dentro de la carpeta [lang]. En mi caso, prefiero que este componente sea del servidor, y que delegue todo el diseño visual a una versión cliente:
// src/app/[lang]/not-found.tsx
import React from "react"
import NotFoundClient from "@/components/NotFoundClient"
export default function NotFound() {
return
}
Lo ideal sería poder leer el parámetro lang directamente desde el servidor, obtener las traducciones con el Server Action y enviarlas al cliente, pero, al momento de escribir este artículo, Next.js no permite acceder directamente a los parámetros como [lang] desde not-found.tsx en el servidor. La comunidad ya ha solicitado esta funcionalidad en esta discusión oficial.
Mientras tanto, existen algunos “trucos” para acceder al parámetro lang desde el servidor, pero decidí no aplicarlos. En su lugar, implementé una solución algo artesanal pero funcional, aprovechando useParams() en el componente cliente. Aunque no sea la solución más elegante ni escalable, para una página simple como not-found, esta aproximación me ha resultado suficiente y efectiva:
"use client"
import { useParams } from "next/navigation"
import...
const navigationItemsES = [
{
icon: BiHome,
label: "Inicio",
description: "Volver al portafolio principal",
href: "/es",
}...
]
const navigationItemsEN = [
{
icon: BiHome,
label: "Home",
description: "Go back to the main page",
href: "/en",
}...
]
const headerTranslationsES = {
"nav_links": {
"projects": "Proyectos",
"contact": "Contacto"
}
}
const headerTranslationsEN = {
"nav_links": {
"projects": "Projects",
"contact": "Contact"
}
}
function NotFoundClient(): React.JSX.Element {
const params = useParams()
const lang = params.lang as string
const navigationItems = lang === "es" ? navigationItemsES : navigationItemsEN
const headerTranslations = lang === "es" ? headerTranslationsES : headerTranslationsEN
return (
{lang === "es" ? "¡Oops! Página no encontrada" : "Oops! Page not found"}
{lang === "es" ? "Parece que esta página se perdió en..." : "It seems that this page has been lost in..."}
{navigationItems.map((item: any) => (
))}
)
}
export default NotFoundClient
Cómo uso las traducciones en mis componentes
// src/app/[lang]/page.tsx
import React from "react"
import { Metadata } from "next"
import Hero from "@/components/Hero"
import About from "@/components/About"
import Footer from "@/components/Footer"
import TransitionPage from "@/components/TransitionPage"
import getTranslations from "@/actions/translate"
async function HomePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
const heroTranslations = await getTranslations(lang, "hero")
const aboutTranslations = await getTranslations(lang, "about")
const footerTranslations = await getTranslations(lang, "footer")
return (
<>
>
)
}
export default HomePage
Lecciones Aprendidas y Conclusión
Implementar i18n en Next.js 15 me enseñó que:
Esta implementación me permitió tener un sitio full multilingüe con excelente SEO, máximo rendimiento y mantenibilidad. Lo mejor de todo: es escalable para añadir más idiomas fácilmente.
¿Te animas a internacionalizar tu próximo proyecto? Si tienes preguntas o quieres profundizar en algún aspecto, no dudes en contactarme. ¡Estoy aquí para ayudar!