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.