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:
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:
npm install @formatjs/intl-localematcher negotiator
npm install -D @types/negotiator # if you use TypeScript
My Recommended Folder Structure:
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:
// 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:
// 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:
// 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:
// 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
// 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:
// 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. 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:
// 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:
// 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:
"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
// 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:
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.