Next.js Internationalization with next-intl — Complete App Router i18n Guide

Building applications for a global audience requires more than just translating strings. You need locale-aware routing, right-to-left (RTL) layout support, pluralization rules, date and number formatting, and a developer experience that scales as your app grows. next-intl has become the go-to solution for internationalization in Next.js App Router applications, offering type-safe translations, server component support, and seamless integration with React Server Components.
In this guide, you will build a fully internationalized Next.js application supporting English, Arabic (RTL), and French — from project setup to production deployment patterns.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- Basic knowledge of Next.js App Router (layouts, pages, server components)
- Familiarity with TypeScript
- A code editor like VS Code with the i18n Ally extension (recommended)
What You Will Build
A multi-language Next.js application featuring:
- Locale-based routing (
/en/about,/ar/about,/fr/about) - Automatic locale detection and redirection
- RTL layout support for Arabic
- Type-safe translation messages
- Dynamic content with interpolation and pluralization
- Locale-aware date and number formatting
- A language switcher component
- SEO-optimized with proper
hreflangtags
Step 1: Create a New Next.js Project
Start by scaffolding a fresh Next.js application:
npx create-next-app@latest my-i18n-app --typescript --tailwind --eslint --app --src-dir
cd my-i18n-appInstall next-intl:
npm install next-intlYour project structure should look like this:
my-i18n-app/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── ...
├── package.json
└── tsconfig.json
Step 2: Define Your Internationalization Configuration
Create the i18n configuration file that defines your supported locales and default locale.
Create src/i18n/config.ts:
export const locales = ["en", "ar", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
ar: "العربية",
fr: "Français",
};
// Locales that use right-to-left text direction
export const rtlLocales: Locale[] = ["ar"];
export function isRtlLocale(locale: Locale): boolean {
return rtlLocales.includes(locale);
}Step 3: Create Translation Message Files
Create a messages directory at your project root with a JSON file for each locale.
messages/en.json:
{
"Metadata": {
"title": "My International App",
"description": "A fully internationalized Next.js application"
},
"Navigation": {
"home": "Home",
"about": "About",
"contact": "Contact",
"blog": "Blog"
},
"HomePage": {
"title": "Welcome to Our Platform",
"subtitle": "Build global applications with ease",
"cta": "Get Started",
"features": {
"title": "Why Choose Us",
"speed": "Lightning Fast",
"speedDescription": "Optimized for performance across all regions",
"i18n": "Built for Global",
"i18nDescription": "Native support for {count, plural, =1 {# language} other {# languages}}",
"secure": "Enterprise Security",
"secureDescription": "Bank-grade encryption and compliance"
},
"stats": {
"users": "{count, number} active users",
"countries": "Available in {count, number} countries",
"uptime": "{value}% uptime"
}
},
"AboutPage": {
"title": "About Us",
"description": "We have been building international software since {year}.",
"team": "Our Team",
"teamSize": "We are a team of {count, plural, =1 {# person} other {# people}}."
},
"ContactPage": {
"title": "Get in Touch",
"form": {
"name": "Your Name",
"email": "Email Address",
"message": "Message",
"submit": "Send Message",
"success": "Message sent successfully!",
"error": "Failed to send message. Please try again."
}
},
"Common": {
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Try Again",
"back": "Go Back",
"lastUpdated": "Last updated: {date, date, medium}"
},
"LanguageSwitcher": {
"label": "Language",
"current": "Current language: {language}"
}
}messages/ar.json:
{
"Metadata": {
"title": "تطبيقي الدولي",
"description": "تطبيق Next.js متعدد اللغات بالكامل"
},
"Navigation": {
"home": "الرئيسية",
"about": "من نحن",
"contact": "اتصل بنا",
"blog": "المدونة"
},
"HomePage": {
"title": "مرحباً بكم في منصتنا",
"subtitle": "بناء تطبيقات عالمية بسهولة",
"cta": "ابدأ الآن",
"features": {
"title": "لماذا تختارنا",
"speed": "سرعة فائقة",
"speedDescription": "محسّن للأداء في جميع المناطق",
"i18n": "مصمم للعالمية",
"i18nDescription": "{count, plural, =1 {لغة واحدة} two {لغتان} few {# لغات} many {# لغة} other {# لغة}}",
"secure": "أمان مؤسسي",
"secureDescription": "تشفير بمستوى البنوك والامتثال"
},
"stats": {
"users": "{count, number} مستخدم نشط",
"countries": "متاح في {count, number} دولة",
"uptime": "{value}٪ وقت التشغيل"
}
},
"AboutPage": {
"title": "من نحن",
"description": "نبني برمجيات دولية منذ عام {year}.",
"team": "فريقنا",
"teamSize": "نحن فريق من {count, plural, =1 {شخص واحد} two {شخصين} few {# أشخاص} many {# شخصاً} other {# شخص}}."
},
"ContactPage": {
"title": "تواصل معنا",
"form": {
"name": "اسمك",
"email": "البريد الإلكتروني",
"message": "الرسالة",
"submit": "إرسال الرسالة",
"success": "تم إرسال الرسالة بنجاح!",
"error": "فشل إرسال الرسالة. يرجى المحاولة مرة أخرى."
}
},
"Common": {
"loading": "جارٍ التحميل...",
"error": "حدث خطأ ما",
"retry": "حاول مرة أخرى",
"back": "العودة",
"lastUpdated": "آخر تحديث: {date, date, medium}"
},
"LanguageSwitcher": {
"label": "اللغة",
"current": "اللغة الحالية: {language}"
}
}messages/fr.json:
{
"Metadata": {
"title": "Mon Application Internationale",
"description": "Une application Next.js entièrement internationalisée"
},
"Navigation": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact",
"blog": "Blog"
},
"HomePage": {
"title": "Bienvenue sur Notre Plateforme",
"subtitle": "Créez des applications mondiales facilement",
"cta": "Commencer",
"features": {
"title": "Pourquoi Nous Choisir",
"speed": "Ultra Rapide",
"speedDescription": "Optimisé pour la performance dans toutes les régions",
"i18n": "Conçu pour le Monde",
"i18nDescription": "{count, plural, =1 {# langue} other {# langues}} prises en charge nativement",
"secure": "Sécurité Entreprise",
"secureDescription": "Chiffrement de niveau bancaire et conformité"
},
"stats": {
"users": "{count, number} utilisateurs actifs",
"countries": "Disponible dans {count, number} pays",
"uptime": "{value} % de disponibilité"
}
},
"AboutPage": {
"title": "À Propos",
"description": "Nous développons des logiciels internationaux depuis {year}.",
"team": "Notre Équipe",
"teamSize": "Nous sommes une équipe de {count, plural, =1 {# personne} other {# personnes}}."
},
"ContactPage": {
"title": "Contactez-Nous",
"form": {
"name": "Votre Nom",
"email": "Adresse Email",
"message": "Message",
"submit": "Envoyer le Message",
"success": "Message envoyé avec succès !",
"error": "Échec de l'envoi. Veuillez réessayer."
}
},
"Common": {
"loading": "Chargement...",
"error": "Une erreur est survenue",
"retry": "Réessayer",
"back": "Retour",
"lastUpdated": "Dernière mise à jour : {date, date, medium}"
},
"LanguageSwitcher": {
"label": "Langue",
"current": "Langue actuelle : {language}"
}
}Notice how the Arabic translations use ICU plural rules with Arabic-specific categories (two, few, many), which next-intl handles automatically through the ICU MessageFormat standard.
Step 4: Set Up next-intl Request Configuration
Create src/i18n/request.ts — this is the core configuration that next-intl uses to resolve messages for each request:
import { getRequestConfig } from "next-intl/server";
import { locales, type Locale } from "./config";
export default getRequestConfig(async ({ requestLocale }) => {
// Validate that the incoming locale is supported
let locale = await requestLocale;
if (!locale || !locales.includes(locale as Locale)) {
locale = "en";
}
return {
locale,
messages: (await import(`../../../messages/${locale}.json`)).default,
timeZone: "UTC",
now: new Date(),
};
});Step 5: Configure Next.js with the next-intl Plugin
Update your next.config.ts (or next.config.mjs) to include the next-intl plugin:
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig = {
// your existing Next.js config
};
export default withNextIntl(nextConfig);Step 6: Set Up Locale-Based Routing
Restructure your app directory to use a [locale] dynamic segment. This is the foundation of locale-based routing.
src/app/
├── [locale]/
│ ├── layout.tsx # Root layout with locale
│ ├── page.tsx # Home page
│ ├── about/
│ │ └── page.tsx # About page
│ └── contact/
│ └── page.tsx # Contact page
├── layout.tsx # Minimal root layout (no locale)
└── not-found.tsx # Global 404
Create the root src/app/layout.tsx (minimal — just passes children through):
import { ReactNode } from "react";
type Props = {
children: ReactNode;
};
export default function RootLayout({ children }: Props) {
return children;
}Now create src/app/[locale]/layout.tsx — this is where the real layout logic lives:
import { ReactNode } from "react";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { locales, type Locale, isRtlLocale } from "@/i18n/config";
import "./globals.css";
type Props = {
children: ReactNode;
params: Promise<{ locale: string }>;
};
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
// Validate the locale
if (!locales.includes(locale as Locale)) {
notFound();
}
// Enable static rendering
setRequestLocale(locale);
// Get all messages for the current locale
const messages = await getMessages();
const dir = isRtlLocale(locale as Locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Key things to note:
generateStaticParamsenables static generation for all localessetRequestLocaleopts into static rendering (required for static generation)NextIntlClientProvidermakes translations available to client components- The
dirattribute is set dynamically —rtlfor Arabic,ltrfor others
Step 7: Create the Middleware for Locale Detection
Create src/middleware.ts at the source root:
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "./i18n/config";
export default createMiddleware({
locales,
defaultLocale,
// Redirect to locale-prefixed paths (e.g., / → /en)
localePrefix: "always",
// Detect locale from Accept-Language header
localeDetection: true,
});
export const config = {
// Match all pathnames except API routes, static files, etc.
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};With this middleware:
- Visiting
/redirects to/en(or the detected locale) - Visiting
/aboutredirects to/en/about - Visiting
/ar/aboutserves the Arabic version directly - The
Accept-Languageheader is used for first-time visitors
Step 8: Build the Home Page with Translations
Create src/app/[locale]/page.tsx:
import { useTranslations } from "next-intl";
import { setRequestLocale } from "next-intl/server";
type Props = {
params: Promise<{ locale: string }>;
};
export default async function HomePage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
return <HomeContent />;
}
function HomeContent() {
const t = useTranslations("HomePage");
return (
<main className="min-h-screen">
{/* Hero Section */}
<section className="flex flex-col items-center justify-center px-4 py-24 text-center">
<h1 className="mb-4 text-5xl font-bold tracking-tight">
{t("title")}
</h1>
<p className="mb-8 max-w-2xl text-xl text-gray-600">
{t("subtitle")}
</p>
<button className="rounded-lg bg-blue-600 px-8 py-3 text-lg font-semibold text-white transition hover:bg-blue-700">
{t("cta")}
</button>
</section>
{/* Features Section */}
<section className="mx-auto max-w-6xl px-4 py-16">
<h2 className="mb-12 text-center text-3xl font-bold">
{t("features.title")}
</h2>
<div className="grid gap-8 md:grid-cols-3">
<FeatureCard
title={t("features.speed")}
description={t("features.speedDescription")}
/>
<FeatureCard
title={t("features.i18n")}
description={t("features.i18nDescription", { count: 3 })}
/>
<FeatureCard
title={t("features.secure")}
description={t("features.secureDescription")}
/>
</div>
</section>
{/* Stats Section */}
<section className="bg-gray-50 px-4 py-16 dark:bg-gray-900">
<div className="mx-auto grid max-w-4xl gap-8 text-center md:grid-cols-3">
<div>
<p className="text-4xl font-bold text-blue-600">
{t("stats.users", { count: 50000 })}
</p>
</div>
<div>
<p className="text-4xl font-bold text-blue-600">
{t("stats.countries", { count: 120 })}
</p>
</div>
<div>
<p className="text-4xl font-bold text-blue-600">
{t("stats.uptime", { value: 99.9 })}
</p>
</div>
</div>
</section>
</main>
);
}
function FeatureCard({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="rounded-xl border p-6 transition hover:shadow-lg">
<h3 className="mb-2 text-xl font-semibold">{title}</h3>
<p className="text-gray-600">{description}</p>
</div>
);
}Notice how useTranslations works in server components — no "use client" directive needed. The t() function supports ICU MessageFormat for pluralization and number formatting out of the box.
Step 9: Build a Language Switcher Component
Create src/components/LanguageSwitcher.tsx:
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import { locales, localeNames, type Locale } from "@/i18n/config";
import { useTransition } from "react";
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("LanguageSwitcher");
const [isPending, startTransition] = useTransition();
function handleLocaleChange(newLocale: string) {
// Replace the current locale segment in the pathname
const segments = pathname.split("/");
segments[1] = newLocale;
const newPathname = segments.join("/");
startTransition(() => {
router.replace(newPathname);
});
}
return (
<div className="relative">
<label htmlFor="locale-select" className="sr-only">
{t("label")}
</label>
<select
id="locale-select"
value={locale}
onChange={(e) => handleLocaleChange(e.target.value)}
disabled={isPending}
className="appearance-none rounded-lg border bg-white px-4 py-2 text-sm font-medium shadow-sm transition hover:border-blue-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 disabled:opacity-50 dark:bg-gray-800 rtl:text-right"
aria-label={t("current", { language: localeNames[locale as Locale] })}
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
{isPending && (
<span className="absolute end-2 top-1/2 -translate-y-1/2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
</span>
)}
</div>
);
}This component:
- Uses
useTransitionfor smooth locale switching without blocking the UI - Replaces the locale segment in the URL path
- Shows a loading spinner during the transition
- Uses
rtl:text-rightfor proper RTL alignment (Tailwind CSS RTL plugin) - Is fully accessible with proper ARIA labels
Step 10: Create a Navigation Component
Create src/components/Navigation.tsx:
import { useTranslations } from "next-intl";
import Link from "next/link";
import LanguageSwitcher from "./LanguageSwitcher";
type Props = {
locale: string;
};
export default function Navigation({ locale }: Props) {
const t = useTranslations("Navigation");
const links = [
{ href: `/${locale}`, label: t("home") },
{ href: `/${locale}/about`, label: t("about") },
{ href: `/${locale}/contact`, label: t("contact") },
{ href: `/${locale}/blog`, label: t("blog") },
];
return (
<nav className="border-b bg-white dark:bg-gray-900">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<div className="flex items-center gap-8">
<Link href={`/${locale}`} className="text-xl font-bold">
MyApp
</Link>
<ul className="flex gap-6">
{links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-gray-600 transition hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
<LanguageSwitcher />
</div>
</nav>
);
}Add the navigation to your locale layout:
// In src/app/[locale]/layout.tsx, add inside the <body> tag:
import Navigation from "@/components/Navigation";
// ...inside the return:
<body>
<NextIntlClientProvider messages={messages}>
<Navigation locale={locale} />
{children}
</NextIntlClientProvider>
</body>Step 11: Handle RTL Layouts with Tailwind CSS
Tailwind CSS v3.3+ includes built-in RTL support using the rtl: and ltr: variants. Since we set the dir attribute in the layout, these variants work automatically.
Update your tailwind.config.ts to ensure RTL mode is enabled:
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;Tailwind v4 has RTL support enabled by default. For v3, make sure you are using v3.3 or later.
Here are the key patterns for RTL-aware layouts:
{/* Use logical properties instead of physical ones */}
{/* Instead of ml-4 / mr-4, use ms-4 / me-4 */}
<div className="ms-4">Margin at the start (left in LTR, right in RTL)</div>
<div className="me-4">Margin at the end (right in LTR, left in RTL)</div>
{/* Instead of pl-4 / pr-4, use ps-4 / pe-4 */}
<div className="ps-6 pe-2">Padding start and end</div>
{/* Instead of left-0 / right-0, use start-0 / end-0 */}
<div className="absolute start-0">Positioned at the start</div>
{/* For directional icons or elements that need to flip */}
<svg className="rtl:rotate-180">→</svg>
{/* Text alignment */}
<p className="text-start">Aligned to the start of the text direction</p>
{/* Border radius - use logical properties */}
<div className="rounded-s-lg">Rounded on the start side</div>
{/* Flexbox and grid - these are automatically RTL-aware */}
<div className="flex gap-4">Items flow correctly in both directions</div>The most important rule: use logical properties (start/end) instead of physical ones (left/right). This single change handles 90% of RTL layout issues.
Step 12: Add Type Safety to Translations
One of the most powerful features of next-intl is type-safe translations. Create a type declaration file to get autocomplete and compile-time checks.
Create src/i18n/types.ts:
import en from "../../messages/en.json";
// Use the English messages as the source of truth for types
type Messages = typeof en;
declare global {
// Use type safe message keys with `auto`
interface IntlMessages extends Messages {}
}Add this to your tsconfig.json includes if it is not already there:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}Now when you use t("HomePage.features.speed"), TypeScript will:
- Autocomplete available message keys
- Show an error if you reference a key that does not exist
- Validate interpolation parameters
Step 13: Format Dates, Numbers, and Relative Times
next-intl provides locale-aware formatting utilities that leverage the Intl API.
import { useFormatter, useNow, useTranslations } from "next-intl";
function StatsSection() {
const format = useFormatter();
const now = useNow();
const t = useTranslations("Common");
// Number formatting
const revenue = format.number(1234567.89, {
style: "currency",
currency: "USD",
});
// en: "$1,234,567.89" | ar: "١٬٢٣٤٬٥٦٧٫٨٩ US$" | fr: "1 234 567,89 $US"
// Date formatting
const date = format.dateTime(new Date("2026-03-18"), {
year: "numeric",
month: "long",
day: "numeric",
});
// en: "March 18, 2026" | ar: "١٨ مارس ٢٠٢٦" | fr: "18 mars 2026"
// Relative time
const lastWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const relative = format.relativeTime(lastWeek);
// en: "7 days ago" | ar: "قبل ٧ أيام" | fr: "il y a 7 jours"
// List formatting
const languages = format.list(["English", "Arabic", "French"], {
type: "conjunction",
});
// en: "English, Arabic, and French"
// ar: "English وArabic وFrench"
// fr: "English, Arabic et French"
return (
<div>
<p>{revenue}</p>
<p>{date}</p>
<p>{relative}</p>
<p>{languages}</p>
<p>{t("lastUpdated", { date: new Date() })}</p>
</div>
);
}Step 14: Handle Dynamic Content and Rich Text
next-intl supports rich text formatting, allowing you to embed components within translated strings.
In your message files:
{
"RichText": {
"welcome": "Welcome to <bold>our platform</bold>. Read our <link>terms of service</link>.",
"highlight": "This feature is <highlight>new</highlight> and available to <badge>Pro</badge> users."
}
}In your component:
import { useTranslations } from "next-intl";
function WelcomeMessage() {
const t = useTranslations("RichText");
return (
<p>
{t.rich("welcome", {
bold: (chunks) => <strong>{chunks}</strong>,
link: (chunks) => (
<a href="/terms" className="text-blue-600 underline">
{chunks}
</a>
),
})}
</p>
);
}This approach keeps your translation files clean while allowing translators to control word order — critical for languages like Arabic where sentence structure differs significantly from English.
Step 15: Add SEO with Locale-Aware Metadata
Create proper metadata for each locale, including hreflang alternate links.
Update src/app/[locale]/layout.tsx:
import { getTranslations } from "next-intl/server";
import { locales } from "@/i18n/config";
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Metadata" });
// Generate alternate links for all locales
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `https://example.com/${loc}`;
}
return {
title: {
default: t("title"),
template: `%s | ${t("title")}`,
},
description: t("description"),
alternates: {
languages,
},
openGraph: {
title: t("title"),
description: t("description"),
locale: locale,
alternateLocale: locales.filter((l) => l !== locale),
},
};
}This generates proper hreflang tags and Open Graph locale metadata, which helps search engines understand your multi-language content and serve the right version to users.
Step 16: Add Locale-Aware Link Component
Create a helper component that automatically prepends the current locale to links:
"use client";
import { useLocale } from "next-intl";
import NextLink from "next/link";
import { ComponentProps } from "react";
type Props = Omit<ComponentProps<typeof NextLink>, "href"> & {
href: string;
};
export default function LocaleLink({ href, ...rest }: Props) {
const locale = useLocale();
// If the href already starts with a locale prefix, use it as-is
const localizedHref = href.startsWith(`/${locale}`)
? href
: `/${locale}${href.startsWith("/") ? href : `/${href}`}`;
return <NextLink href={localizedHref} {...rest} />;
}Usage:
import LocaleLink from "@/components/LocaleLink";
// These automatically get the locale prefix
<LocaleLink href="/about">About</LocaleLink> // → /en/about
<LocaleLink href="/contact">Contact</LocaleLink> // → /ar/contact (if current locale is ar)Step 17: Testing Your i18n Implementation
Run your development server and verify everything works:
npm run devTest these scenarios:
- Default redirect: Visit
http://localhost:3000— you should be redirected to/en(or your browser's detected locale) - Locale switching: Use the language switcher to change between English, Arabic, and French
- RTL layout: Switch to Arabic and verify the layout mirrors correctly
- Direct URL access: Visit
http://localhost:3000/ardirectly - Translation interpolation: Check that numbers, dates, and plurals render correctly in each locale
- 404 handling: Visit
http://localhost:3000/de(unsupported locale) — should show 404
Step 18: Production Optimization Tips
Enable Static Rendering
For best performance, enable static generation for all your localized pages. Add setRequestLocale at the top of every page and layout that uses translations:
import { setRequestLocale } from "next-intl/server";
export default async function Page({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
// ... rest of the component
}Message Splitting
For large applications, avoid loading all translations on every page. Split messages by namespace:
// In src/i18n/request.ts
export default getRequestConfig(async ({ requestLocale }) => {
const locale = await requestLocale;
return {
locale,
messages: (await import(`../../../messages/${locale}.json`)).default,
};
});For even more granular control, you can load only specific namespaces per page using getMessages with a filter.
Bundle Analysis
Check that your translation bundles are not bloating your client-side JavaScript:
npx @next/bundle-analyzerServer components using useTranslations do not add translations to the client bundle — only components with "use client" that use NextIntlClientProvider include translations in the client bundle.
Troubleshooting
Common Issue: "Unable to find next-intl locale"
This usually means the middleware is not matching your routes. Check:
- The
matcherpattern inmiddleware.tsis correct - The middleware file is at
src/middleware.ts(not insideapp/) - You have restarted the dev server after adding the middleware
Common Issue: Hydration Mismatch with RTL
If you see hydration warnings when switching between LTR and RTL:
- Make sure
dirandlangare set on thehtmlelement in the server layout - Add
suppressHydrationWarningto thehtmltag if using a theme provider that modifies attributes
Common Issue: Missing Translations in Client Components
Ensure NextIntlClientProvider wraps your component tree and receives the messages prop. Without it, client components cannot access translations.
Next Steps
Now that you have a solid i18n foundation, consider:
- Adding more locales — just create a new message file and add the locale to your config
- Setting up a translation management system like Crowdin or Lokalise for team-based translation workflows
- Implementing locale-specific content — different images, videos, or layouts per locale
- Adding locale-aware search with proper text analysis per language
- Implementing locale persistence using cookies so returning users get their preferred language
Conclusion
You have built a production-ready internationalized Next.js application with next-intl that supports:
- Type-safe translations with autocomplete
- Locale-based routing with automatic detection
- RTL layout support for Arabic using Tailwind CSS logical properties
- ICU MessageFormat for pluralization and formatting
- SEO-optimized metadata with hreflang tags
- A smooth language switching experience
The combination of Next.js App Router and next-intl provides one of the best developer experiences for building multilingual applications. The server-first approach means translations do not bloat your client bundle, and the ICU MessageFormat support handles the nuances of different languages — from Arabic plural rules to French number formatting — without any custom logic.
Whether you are building for the MENA region, European markets, or a truly global audience, this foundation scales with your application and keeps your translation workflow maintainable as your content grows.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.