Learn how to implement multi-language support in Next.js 15 without changing URLs. Master automatic browser language detection, user preference persistence with cookies, dynamic metadata translation, and seamless integration with both client and server components using next-intl. Includes complete code examples, best practices, and troubleshooting guide for building production-ready multilingual applications.
This documentation demonstrates how to implement multi-language support in a Next.js application without changing the URL structure. Unlike traditional i18n implementations that use route prefixes (e.g., /en/page, /fr/page), this approach maintains clean URLs while still providing full internationalization capabilities.
pnpm create next-app@latest .
Answer the configuration prompts:
pnpm dev
Visit http://localhost:3000 to ensure the project is running correctly.
pnpm add next-intl
This package will appear in your package.json file under dependencies.
Open next.config.ts and add the following configuration:
import type { NextConfig } from "next";
// @ts-expect-error - next-intl requires this import style
const createNextIntlPlugin = require("next-intl/plugin");
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
/* config options here */
};
export default withNextIntl(nextConfig);Important Notes:
require syntax is recommended by next-intl documentation for .ts/.js config files@ts-expect-error comment.mjs/.mts extensions, you can use ES6 import syntaxCreate a new folder and file: i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
export default getRequestConfig(async () => {
// Get stored language preference from cookies
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("my_nextapp_locale")?.value;
// Use cookie locale if it exists, otherwise default to 'en'
const locale = cookieLocale || "en";
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});Key Points:
cookies() must be awaited in Next.js 15my_nextapp_locale) should be unique to avoid conflictsCreate a messages folder in your project root.
Create JSON files for each supported language:
messages/en.json{
"tabTitles": {
"home": "Create Next App"
},
"homePage": {
"listOne": "Get started by editing",
"listTwo": "Save and see your changes instantly."
}
}messages/fr.json{
"tabTitles": {
"home": "Créer une application Next"
},
"homePage": {
"listOne": "Commencez par éditer",
"listTwo": "Enregistrez et voyez vos modifications instantanément."
}
}Important Guidelines:
de.json, es.json, etc.)Modify app/layout.tsx:
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<div className="mx-auto flex h-screen max-w-6xl flex-col">
<Navbar />
<div className="flex flex-grow items-center justify-center">
{children}
</div>
</div>
</NextIntlClientProvider>
</body>
</html>
);
}Key Changes:
NextIntlClientProvidermessages and locale from server functionsasyncCreate components/Navbar.tsx:
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
export default function Navbar() {
const [locale, setLocale] = useState("");
const router = useRouter();
useEffect(() => {
// Check if user has a stored language preference
const cookieLocale = document.cookie
.split("; ")
.find((row) => row.startsWith("my_nextapp_locale="))
?.split("=")[1];
if (cookieLocale) {
// Use stored preference
setLocale(cookieLocale);
} else {
// Detect browser's default language
const browserLocale = navigator.language.slice(0, 2);
setLocale(browserLocale);
// Store browser language as initial preference
document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
router.refresh();
}
}, [router]);
const changeLanguage = (newLocale: string) => {
setLocale(newLocale);
document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
router.refresh();
};
return (
<nav className="flex items-center justify-between p-4">
<h1 className="text-2xl font-bold">Logo</h1>
<div className="flex gap-2">
<button
onClick={() => changeLanguage("en")}
className={`rounded px-4 py-2 ${
locale === "en" ? "bg-blue-500 text-white" : "bg-gray-200"
}`}
>
EN
</button>
<button
onClick={() => changeLanguage("fr")}
className={`rounded px-4 py-2 ${
locale === "fr" ? "bg-blue-500 text-white" : "bg-gray-200"
}`}
>
FR
</button>
</div>
</nav>
);
}Component Features:
Modify app/page.tsx:
import { useTranslations } from "next-intl";
export default function Home() {
const t = useTranslations("homePage");
return (
<main className="flex flex-col items-center gap-8">
<div>
<ol>
<li>
<code>{t("listOne")}</code> app/page.tsx
</li>
<li>{t("listTwo")}</li>
</ol>
</div>
</main>
);
}Usage Notes:
useTranslations('homePage') accesses the homePage object from JSON files'use client' directiveThe language detection follows this priority:
First Visit:
my_nextapp_locale existsnavigator.languageSubsequent Visits:
useEffect(() => {
const cookieLocale = document.cookie
.split("; ")
.find((row) => row.startsWith("my_nextapp_locale="))
?.split("=")[1];
if (cookieLocale) {
setLocale(cookieLocale);
} else {
const browserLocale = navigator.language.slice(0, 2);
setLocale(browserLocale);
document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
router.refresh();
}
}, [router]);When a user changes language:
const changeLanguage = (newLocale: string) => {
setLocale(newLocale);
document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
router.refresh();
};Steps:
my_nextapp_locale (customize as needed)/ (accessible across entire site)To support translated page titles, remove static metadata from layout.tsx and implement generateMetadata in each page:
// DELETE THIS:
export const metadata = {
title: "Create Next App",
description: "...",
};import { getMessages } from "next-intl/server";
import type { AbstractIntlMessages } from "next-intl";
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const messages = (await getMessages()) as AbstractIntlMessages;
const title = messages.tabTitles?.home;
return {
title,
};
}How It Works:
generateMetadata is a Next.js function that runs on the servermy-nextjs-app/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── Navbar.tsx
├── i18n/
│ └── request.ts
├── messages/
│ ├── en.json
│ └── fr.json
├── next.config.ts
└── package.json
import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("my_nextapp_locale")?.value;
const locale = cookieLocale || "en";
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
export default function Navbar() {
const [locale, setLocale] = useState("");
const router = useRouter();
useEffect(() => {
const cookieLocale = document.cookie
.split("; ")
.find((row) => row.startsWith("my_nextapp_locale="))
?.split("=")[1];
if (cookieLocale) {
setLocale(cookieLocale);
} else {
const browserLocale = navigator.language.slice(0, 2);
setLocale(browserLocale);
document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
router.refresh();
}
}, [router]);
const changeLanguage = (newLocale: string) => {
setLocale(newLocale);
document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
router.refresh();
};
return (
<nav className="flex items-center justify-between p-4">
<h1 className="text-2xl font-bold">Logo</h1>
<div className="flex gap-2">
<button
onClick={() => changeLanguage("en")}
className={`rounded px-4 py-2 ${
locale === "en" ? "bg-blue-500 text-white" : "bg-gray-200"
}`}
>
EN
</button>
<button
onClick={() => changeLanguage("fr")}
className={`rounded px-4 py-2 ${
locale === "fr" ? "bg-blue-500 text-white" : "bg-gray-200"
}`}
>
FR
</button>
</div>
</nav>
);
}import { useTranslations } from "next-intl";
import { getMessages } from "next-intl/server";
import type { AbstractIntlMessages } from "next-intl";
export async function generateMetadata() {
const messages = (await getMessages()) as AbstractIntlMessages;
const title = messages.tabTitles?.home;
return { title };
}
export default function Home() {
const t = useTranslations("homePage");
return (
<main className="flex flex-col items-center gap-8">
<div>
<ol>
<li>
<code>{t("listOne")}</code> app/page.tsx
</li>
<li>{t("listTwo")}</li>
</ol>
</div>
</main>
);
}messages/
├── en/
│ ├── common.json
│ ├── home.json
│ └── about.json
└── fr/
├── common.json
├── home.json
└── about.json
/ for site-wide access)// Create a type for your messages
type Messages = typeof import("./messages/en.json");
// Use it for type-safe translations
const t = useTranslations<Messages>("homePage");To add a new language:
messages/de.json<button onClick={() => changeLanguage("de")}>DE</button>useTranslations directly'use client' directive and use useTranslations1. Translations not updating:
router.refresh() is called after language change2. TypeScript errors in next.config.ts:
@ts-expect-error comment as shown3. Cookie not persisting:
path=/ is set4. Browser language not detected:
navigator.language browser compatibility.slice(0, 2) to get language code onlyThis implementation provides a clean, URL-friendly approach to internationalization in Next.js. It automatically detects user preferences while respecting their choices across sessions. The setup works seamlessly with both client and server components, making it suitable for any Next.js application architecture.
/en or /fr prefixes)Document Version: 1.0
Last Updated: December 2024
Next.js Version: 15.x
next-intl Version: Latest