Have you ever built a cool Next.js app… only to realize it only works in one language?
That’s fine if your users all speak English.
But what if tomorrow you want to reach users in France, Nigeria, or Brazil?
That’s where internationalization (i18n) comes in.
In this tutorial, I’ll show you step by step how to integrate next-intl into your Next.js project — with code, explanations, and screenshots.
Let’s go 🚀
If you don’t already have one, run:
npx create-next-app my-app
cd my-app
Inside your project folder, run:
npm install next-intl
Create a new folder called messages/
inside your project.
Add your translation files:
messages/en.json
{
"greeting": "Hello 👋",
"welcome": "Welcome to my Next.js multilingual app!",
"description": "This app is powered by Next.js 15 and next-intl.",
"cta": "Start exploring features below 🚀",
"feature1": "🌍 Switch between English and French instantly.",
"feature2": "⚡ Enjoy blazing fast rendering with Turbopack.",
"feature3": "🛠️ Built with love using React, TypeScript & Tailwind CSS."
}
messages/fr.json
{
"greeting": "Salut 👋",
"welcome": "Bienvenue sur mon application multilingue Next.js !",
"description": "Cette application fonctionne avec Next.js 15 et next-intl.",
"cta": "Commencez à explorer les fonctionnalités ci-dessous 🚀",
"feature1": "🌍 Passez instantanément de l'anglais au français.",
"feature2": "⚡ Profitez d’un rendu ultra-rapide grâce à Turbopack.",
"feature3": "🛠️ Développée avec amour en React, TypeScript et Tailwind CSS."
}
📸 Screenshot idea: Show the
messages/
folder in your project with both files.
In Next.js App Router, wrap your app with NextIntlClientProvider
and load messages dynamically.
app/[locale]/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { NextIntlClientProvider } from "next-intl";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
type Props = {
children: React.ReactNode;
params: { locale: string };
};
export default async function RootLayout({
children,
params: { locale },
}: Props) {
const messages = (await import(`../../messages/${locale}.json`)).default;
return (
<html lang={locale}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
To enable locale-based routing, add these files:
i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "fr"],
defaultLocale: "en",
});
middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
};
This ensures your app automatically detects and routes users based on their language.
app/[locale]/page.tsx
import LanguageSwitcher from "@/components/language-switcher";
import { useTranslations } from "next-intl";
export default function HomePage() {
const t = useTranslations();
return (
<main className="p-8 max-w-3xl mx-auto">
<LanguageSwitcher />
<h1 className="text-4xl font-bold mb-4">{t("greeting")}</h1>
<p className="text-lg mb-6">{t("welcome")}</p>
<section className="bg-gray-100 p-6 rounded-2xl shadow-md">
<p className="mb-4 text-gray-700">{t("description")}</p>
<h2 className="text-2xl font-semibold mb-2">{t("cta")}</h2>
<ul className="list-disc pl-6 space-y-2 text-gray-800">
<li>{t("feature1")}</li>
<li>{t("feature2")}</li>
<li>{t("feature3")}</li>
</ul>
</section>
</main>
);
}
components/language-switcher.tsx
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useLocale } from "next-intl";
import { Globe } from "lucide-react";
export default function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const switchTo = (lang: string) => {
if (lang !== locale) {
const segments = pathname.split("/");
segments[1] = lang;
router.replace(segments.join("/"));
}
};
return (
<div className="flex items-center">
<div
className="flex items-center bg-white rounded-full px-4 py-1 shadow-md cursor-pointer select-none border-[1px] border-neutral-200"
onClick={() => switchTo(locale === "en" ? "fr" : "en")}
style={{ minWidth: 60 }}
>
<Globe className="w-5 h-5 mr-2 text-neutral-300" />
<span className="font-semibold text-gray-700">
{locale.toUpperCase()}
</span>
</div>
</div>
);
}
📸 Screenshot idea: Show the switcher in action.
Internationalization is not just about translation.
It’s about respecting your users and making them feel like your app was built for them.
And here’s the best part: even as a junior developer, adding i18n to your projects instantly makes them look more professional and production-ready.
So next time you start a project → don’t forget to think global 🌍.
If this tutorial helped you, feel free to:
- ⭐ Check out my projects on GitHub
- 💬 Connect with me on LinkedIn
- 📝 Read more articles on Medium
We’re all learning. Let’s grow together 🚀