writing/tutorial/2026/03
TutorialMar 18, 2026·30 min read

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

Build a fully internationalized Next.js application with next-intl. This comprehensive guide covers App Router setup, RTL language support, dynamic routing, pluralization, and production-ready i18n patterns.

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 hreflang tags

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-app

Install next-intl:

npm install next-intl

Your 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:

  • generateStaticParams enables static generation for all locales
  • setRequestLocale opts into static rendering (required for static generation)
  • NextIntlClientProvider makes translations available to client components
  • The dir attribute is set dynamically — rtl for Arabic, ltr for 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 /about redirects to /en/about
  • Visiting /ar/about serves the Arabic version directly
  • The Accept-Language header 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 useTransition for 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-right for 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.

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 dev

Test these scenarios:

  1. Default redirect: Visit http://localhost:3000 — you should be redirected to /en (or your browser's detected locale)
  2. Locale switching: Use the language switcher to change between English, Arabic, and French
  3. RTL layout: Switch to Arabic and verify the layout mirrors correctly
  4. Direct URL access: Visit http://localhost:3000/ar directly
  5. Translation interpolation: Check that numbers, dates, and plurals render correctly in each locale
  6. 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-analyzer

Server 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:

  1. The matcher pattern in middleware.ts is correct
  2. The middleware file is at src/middleware.ts (not inside app/)
  3. 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:

  1. Make sure dir and lang are set on the html element in the server layout
  2. Add suppressHydrationWarning to the html tag 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.