Logo
Next.js

Cómo Implementé Internacionalización en Next.js 15: Guía Práctica desde Mi Experiencia

Descubre cómo implementé i18n en mi sitio web con Next.js 15 usando el App Router. Te muestro paso a paso las mejores prácticas que aprendí en el proceso.

Jorge Reyes

Jorge Reyes

Desarrollador Full Stack

09/07/2025
10 min lectura
Artículo técnico
Next.jsReacti18nTypeScriptDesarrollo Web
Cómo Implementé Internacionalización en Next.js 15: Guía Práctica desde Mi Experiencia

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:

  • 72.4% de los consumidores prefieren navegar en su idioma nativo, según el reporte de Common Sense Advisory
  • 40% nunca compra en sitios web en otros idiomas, como reporta Harvard Business Review
  • 56.2% de los usuarios dicen que la información en su propio idioma es más importante que el precio
  • Las aplicaciones multiidioma tienen 3x más engagement
  • 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:

    bash
    npm install @formatjs/intl-localematcher negotiator
    npm install -D @types/negotiator  # Solo para TypeScript
    

    Mi estructura de carpetas recomendada:

    text
    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":

    typescript
    // 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:

    typescript
    // 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.

    json
    // 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:

    typescript
    // 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

    typescript
    // 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.

    typescript
    // 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 (
        
    {selected.alt} {selectedLanguage} {isOpen && ( {languages.map((language) => ( handleChangeLanguage(language.code)} > {`${language.name} {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:

    typescript
    // 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:

    typescript
    // 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:

    typescript
    "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

    typescript
    // 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:

  • Las soluciones simples suelen ser las más efectivas
  • El App Router redefine por completo la forma en que abordamos el enrutamiento multilingüe
  • El rendimiento marca la diferencia. Las Server Actions optimizan la carga inicial de manera significativa
  • La experiencia de usuario es clave las transiciones suaves elevan la calidad percibida del producto
  • 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!