Logo
Next.js

How I Implemented Internationalization in Next.js 15: A Practical Guide from My Experience

Discover how I implemented i18n in my website using Next.js 15 and the App Router. I walk you through the best practices I learned along the way.

Jorge Reyes

Jorge Reyes

Full Stack Developer

07/09/2025
10 min read
Technical article
Next.jsReacti18nTypeScriptWeb Development
How I Implemented Internationalization in Next.js 15: A Practical Guide from My Experience

How I Implemented Internationalization in Next.js 15: A Practical Guide from My Experience

Hey community 👋 As a full stack developer, I’m always looking for ways to enhance the user experience in my projects. Today, I want to share how I implemented internationalization in my personal portfolio using Next.js 15.

When I decided to make my site multilingual, I explored several options—but I was aiming for a lightweight, efficient solution that could fully leverage the new capabilities of the App Router. The outcome was so satisfying that I’ve put together a step-by-step guide so you can do it too.

Why Internationalization Matters More Than Ever

Recent studies from organizations focused on user experience and global commerce reveal:

  • 72.4% of consumers prefer browsing in their native language, according to Common Sense Advisory
  • 40% never make purchases on websites that aren’t in their language, as reported by Harvard Business Review
  • 56.2% of users say that having content in their own language is more important than price
  • Multilingual applications see 3x higher engagement
  • These numbers made it clear to me that internationalization isn’t a nice-to-have—it’s a strategic necessity for any developer aiming to build products with global reach.

    Initial Setup: Minimal but Powerful

    You only need two lightweight dependencies:

    bash
    npm install @formatjs/intl-localematcher negotiator
    npm install -D @types/negotiator  # if you use TypeScript
    

    My Recommended Folder Structure:

    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
    

    Step-by-Step Implementation: What Actually Works

    1. Smart Language Detection

    I built a utility that elegantly detects the user's language by reading the "Accept-Language" header using Negotiator:

    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 That Does the Heavy Lifting

    The middleware is the core of the entire system. I fine-tuned it to be as efficient as possible, intercepting every request and assigning the correct language when needed. I also added specific conditions to prevent it from interfering with static assets or routes that don’t represent actual pages. Of course, these exclusions can be adjusted based on your project’s needs:

    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. Organized Translations

    To keep things clear and scalable, I structure translations in modular JSON files by component, following the atomic design approach. Each “molecule” has its own translation block, which improves maintainability and avoids unnecessary duplication:

    json
    // src/locales/en.json
    {
      "hero": {
        "greeting": "Hello!, I'm",
        "specialization": "and I specialize in creating modern interfaces and scalable...",
        "download_cv": "Download CV",
        "aria_label_send_email": "Send an email to jorgereyes@jurgenkings.com"
      },
      "about": {
        "title": "I'm",
        "paragraph_1": "Under my professional alias Jurgen Kings, I build full-stack solutions focused on the MERN stack...",
        "paragraph_2": "I'm currently expanding my expertise into mobile development with React Native..."
      },
      "footer": {
        "title": "We Build Something Incredible Together!",
        "all_rights_reserved": "All rights reserved."
      }
    }
    

    đź’ˇ Pro tip: I use translate.i18next.com for consistent and instant translations.

    4. Server Actions for Maximum Performance

    I implemented an optimized server-side solution that fetches translations for each component separately, improving both organization and performance:

    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
    

    Optimizations That Make a Real Difference

    1. Flawless International SEO

    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. Language Selector with Smooth Animations

    I designed a fully functional component using Framer Motion to deliver smooth transitions and a more pleasant user experience. You can plug it directly into your projects and easily adapt it to your multilingual needs:

    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. Handling Invalid Routes (404)

    To catch any invalid route within a specific language, I use a special file: [...not-found]/page.tsx. This file acts as a catch-all and redirects directly to Next.js’s error system:

    typescript
    // src/app/[lang]/[...not-found]/page.tsx
    import { notFound } from "next/navigation"
    
    const catchAll = () => {
      return notFound()
    }
    
    export default catchAll
    

    This approach ensures that any malformed URL within a language scope (e.g. /en/xyz) is properly redirected to the corresponding not-found.tsx.

    That said, the not-found.tsx file must be placed inside the [lang] folder. In my case, I prefer this component to be server-side, delegating all visual rendering to a client-side version:

    typescript
    // src/app/[lang]/not-found.tsx
    import React from "react"
    import NotFoundClient from "@/components/NotFoundClient"
    
    export default function NotFound() {
      return 
    }
    

    Ideally, we’d be able to read the lang parameter directly on the server, fetch translations via a Server Action, and pass them to the client. However, at the time of writing this article, Next.js does not allow direct access to route parameters like [lang] from not-found.tsx on the server. The community has already requested this feature in this discussion.

    In the meantime, there are a few “workarounds” to access the lang parameter server-side, but I chose not to apply them. Instead, I implemented a somewhat manual but functional solution using useParams() in the client component. While it may not be the most elegant or scalable approach, for a simple page like not-found, it has proven to be sufficient and effective:

    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

    How I Use Translations in My Components

    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

    Lessons Learned and Final Thoughts

    Implementing i18n in Next.js 15 taught me that:

  • Simple solutions are often the most effective
  • The App Router completely redefines how we approach multilingual routing
  • Performance truly matters — Server Actions make a noticeable impact on initial load
  • User experience is everything — smooth transitions elevate the perceived quality of your product
  • This implementation gave me a fully multilingual site with excellent SEO, top-tier performance, and strong maintainability. Best of all, it’s scalable and ready to support additional languages with ease. Thinking about internationalizing your next project? If you have questions or want to dive deeper into any part of this guide, feel free to reach out. I’m here to help.