Internationalization (i18n) and Middleware: next-intl + RTL Layout
Internationalization isn't just about translation—it's a systematic engineering process involving routing, locale detection, and RTL layout.
1. What You'll Learn
next-intlInstallation and Integration of the Internationalization Framework- Middleware Routing Policies (
Accept-LanguageDetection + Cookie Persistence + URL Rewriting) - Multilingual dictionary management (
en.json/ar.jsonstructured translation) generateStaticParamsPre-generate multilingual static pages- RTL layout support (Arabic text direction adaptation)
- International Formatting of Dates, Numbers, and Currency
2. The True Story of an International Product Manager
(1) Pain Point: 47 negative reviews from users in the Middle East
After Alice's TaskFlow went live, it received a large number of complaints from users in Saudi Arabia and the United Arab Emirates:
"The dashboard is still in English, the date format 07/06/2026 makes no sense to them (Saudi Arabia uses 06/07/2026), and the number 1,234.56 displays incorrectly in Arab countries. Worst of all—the page scrolls from right to left, and the entire layout is reversed."
| Issue | User Feedback | Number of Users Affected |
|---|---|---|
| Page not translated into Arabic | "Can't understand the menu" | 47 negative reviews |
| Incorrect date format | "The date is reversed" | 32 complaints |
| Layout not optimized for RTL | "The button is on the right" | 28 complaints |
| Currency Symbol Error | "Price Displayed in USD" | 15 Complaints |
(2) The next-intl + Middleware Solution
Use
next-intlto provide an internationalization framework; the middleware automatically switches based on the browser's language.
// middleware.ts — International Routing Core
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['zh', 'en', 'ja', 'ar'],
defaultLocale: 'zh',
localePrefix: 'always' // URL Always include the region prefix
})
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)']
}
(3) Revenue
| Dimension | Before the redesign | After the redesign |
|---|---|---|
| Supported Languages | Chinese only | 4 languages: Chinese, English, Japanese, and Arabic |
| Language Detection | Manual Selection | Auto-detect browser language |
| RTL Support | ❌ Not Supported | ✅ Full Mirror Layout |
| Date/Number | Hard-coded Chinese format | Intl API auto-formatting |
| Saudi Users NPS | 32 | 78 |
3. Installing next-intl and Project Structure
(1) Installation and Directory Structure
npm install next-intl
graph TB
A[next-intl] --> B[i18n/request.ts<br/>Server Configuration]
A --> C[messages/<br/>Translation Dictionary]
C --> D[en.json<br/>English]
C --> E[zh.json<br/>Chinese]
C --> F[ja.json<br/>Japanese]
C --> G[ar.json<br/>Arabic]
A --> H[middleware.ts<br/>Language Detection + Rewrite]
A --> I[app/[locale]/<br/>Dynamic Routing Segments]
style A fill:#cce5ff
style H fill:#d4edda
style I fill:#fff3cd
| File | Purpose |
|---|---|
i18n/request.ts |
Server-side i18n configuration entry point |
messages/en.json |
English Dictionary |
messages/ar.json |
Arabic Dictionary (with RTL Markup) |
middleware.ts |
Automatic Language Detection + URL Rewriting |
app/[locale]/ |
Dynamic Regional Routing Segment |
(2) Initial Configuration
// i18n/request.ts — next-intl Server Configuration
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
}
})
// i18n/routing.ts — Sharing Router Configurations
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['zh', 'en', 'ja', 'ar'],
defaultLocale: 'zh',
localeDetection: true,
localePrefix: 'as-needed' // Omit the prefix for the default language
})
▶ Example: Complete Middleware Configuration
// middleware.ts — Language Detection + Cookie Persistence
import createMiddleware from 'next-intl/middleware'
import { NextRequest, NextResponse } from 'next/server'
const intlMiddleware = createMiddleware({
locales: ['zh', 'en', 'ja', 'ar'],
defaultLocale: 'zh',
localeDetection: true,
localePrefix: 'always'
})
export default function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
// API / _next Do not process
if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico') {
return NextResponse.next()
}
return intlMiddleware(req)
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}
4. Middleware Routing Policies
(1) Three Routing Modes
graph TB
A[User Access] --> B{Middleware Testing}
B --> C[Inspection Cookie<br/>next-intl-locale]
B --> D[Inspect Accept-Language<br/>HTTP Header]
B --> E[Default Language]
C --> F[Selection Made → Usage Options]
D --> G[First Visit → Detect Browser Language]
E --> H[No matches found → Default Language]
F --> I[URL Rewrite /{locale}/...]
G --> I
H --> I
I --> J[Write Cookie<br/>Persistence Options]
style B fill:#fff3cd
style I fill:#d4edda
style J fill:#cce5ff
| Routing Mode | URL Format | localePrefix |
Applicable Scenarios |
|---|---|---|---|
| Always Prefix | /en/about / /ar/about |
'always' |
Equal display for all languages |
| On-Demand Prefix | /about (default) / /en/about |
'as-needed' |
Default Language SEO-Friendly |
| Never Prefix | /about |
'never' |
Cookie-driven (not recommended) |
(2) Accept-Language Detection + Cookie Persistence
// middleware.ts — Manual Control of Detection Logic
import { NextRequest, NextResponse } from 'next/server'
import { routing } from './i18n/routing'
const COOKIE_NAME = 'NEXT_LOCALE'
export default function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const cookieLocale = req.cookies.get(COOKIE_NAME)?.value
const acceptLanguage = req.headers.get('Accept-Language')?.split(',')[0]?.split('-')[0]
const browserLocale = acceptLanguage && routing.locales.includes(acceptLanguage as any) ? acceptLanguage : null
// Matching Languages
const matchedLocale = cookieLocale || browserLocale || routing.defaultLocale
// The path already has a language prefix
const pathLocale = routing.locales.find((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)
if (pathLocale) return NextResponse.next()
// Rewrite URL and set Cookie
const url = new URL(`/${matchedLocale}${pathname}`, req.url)
const res = NextResponse.rewrite(url)
res.cookies.set(COOKIE_NAME, matchedLocale, { maxAge: 60 * 60 * 24 * 365 })
return res
}
export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'] }
▶ Example: Language Switch Component
// components/LocaleSwitcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { usePathname, useRouter } from '@/i18n/routing'
import { useTransition } from 'react'
const locales = [
{ code: 'zh', label: 'Chinese' },
{ code: 'en', label: 'English' },
{ code: 'ja', label: 'Japanese' },
{ code: 'ar', label: 'العربية' }
]
export default function LocaleSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const [isPending, startTransition] = useTransition()
const switchLocale = (nextLocale: string) => {
startTransition(() => {
router.replace(pathname, { locale: nextLocale })
})
}
return (
<select
value={locale}
onChange={(e) => switchLocale(e.target.value)}
disabled={isPending}
style={{ padding: '0.25rem 0.5rem' }}
>
{locales.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
)
}
5. Multilingual Dictionary Management
(1) Dictionary File Structure
// messages/zh.json
{
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"projects": "Project",
"settings": "Settings",
"signIn": "Log In",
"signOut": "Exit"
},
"home": {
"title": "TaskFlow - Team Collaboration Platform",
"subtitle": "Help 10,000+ Highly Effective Team Collaboration",
"cta": "Get Started for Free"
},
"dashboard": {
"welcome": "Welcome back,{name}",
"stats": {
"members": "{count} Famous members",
"projects": "{count} projects",
"tasks": "{count} A task"
},
"activity": "Recent Events"
},
"common": {
"loading": "Loading......",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Confirm"
}
}
// messages/ar.json
{
"nav": {
"home": "الرئيسية",
"dashboard": "لوحة التحكم",
"projects": "المشاريع",
"settings": "الإعدادات",
"signIn": "تسجيل الدخول",
"signOut": "تسجيل الخروج"
},
"home": {
"title": "TaskFlow - منصة التعاون الجماعي",
"subtitle": "نساعد أكثر من 10,000 فريق على العمل بكفاءة",
"cta": "ابدأ مجاناً"
},
"dashboard": {
"welcome": "مرحباً بعودتك، {name}",
"stats": {
"members": "{count} أعضاء",
"projects": "{count} مشروعاً",
"tasks": "{count} مهمة"
},
"activity": "النشاطات الأخيرة"
},
"common": {
"loading": "جارٍ التحميل...",
"error": "حدث خطأ",
"save": "حفظ",
"cancel": "إلغاء",
"delete": "حذف",
"confirm": "تأكيد"
}
}
(2) Using Translations in Components
// Server Component:Usage t() Function
import { getTranslations } from 'next-intl/server'
export default async function HomePage() {
const t = await getTranslations('home')
return (
<div>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
<button>{t('cta')}</button>
</div>
)
}
// Client Component:useTranslations Hook
'use client'
import { useTranslations } from 'next-intl'
export default function DashboardHeader({ name }: { name: string }) {
const t = useTranslations('dashboard')
return (
<h1>{t('welcome', { name })}</h1>
)
}
Welcome back, Alice
💻 Output (Arabic): مرحباً بعودتك، Alice
▶ Example: Translations containing complex numbers
// Dictionary
{
"tasks": {
"remaining": "{count, plural, =0 {No tasks} one {# A task} other {# A task}}"
}
}
import { useTranslations } from 'next-intl'
export default function TaskCount({ count }: { count: number }) {
const t = useTranslations('tasks')
return <span>{t('remaining', { count })}</span>
}
No tasks
💻 Output (count=1): 1 task
💻 Output (count=5): 5 tasks
next-intl Uses ICU MessageFormat syntax and supports advanced features such as plural forms, options, and number formatting.
6. generateStaticParams: Pre-generate multilingual pages
(1) Principles of Static Pre-generation
graph LR
A[generateStaticParams] --> B[Back locale List]
B --> C[zh: /zh/blog/post-1]
B --> D[en: /en/blog/post-1]
B --> E[ja: /ja/blog/post-1]
B --> F[ar: /ar/blog/post-1]
C --> G[Generated during build: 4 HTML files]
D --> G
E --> G
F --> G
style A fill:#cce5ff
style G fill:#d4edda
| Language | HTML File | Path |
|---|---|---|
| Chinese | out/zh/blog/post-1.html |
/zh/blog/post-1 |
| English | out/en/blog/post-1.html |
/en/blog/post-1 |
| Japanese | out/ja/blog/post-1.html |
/ja/blog/post-1 |
| Arabic | out/ar/blog/post-1.html |
/ar/blog/post-1 |
(2) The "locale" parameter in the root layout
// app/[locale]/layout.tsx — Root Layout Receive locale Parameters
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'
type Props = { children: React.ReactNode; params: Promise<{ locale: string }> }
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params
// Verification locale Is it valid?
if (!routing.locales.includes(locale as any)) notFound()
const messages = await getMessages()
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
▶ Example: Static Generation of Multilingual Blog Posts
// app/[locale]/blog/[slug]/page.tsx — Multilingual SSG
import { getTranslations } from 'next-intl/server'
import { routing } from '@/i18n/routing'
type Props = { params: Promise<{ locale: string; slug: string }> }
// Pre-generate article pages for all languages
export async function generateStaticParams() {
const posts = await fetch('https://api.taskflow.io/blog/posts').then(r => r.json())
return routing.locales.flatMap((locale) =>
posts.map((post: { slug: string }) => ({ locale, slug: post.slug }))
)
}
export async function generateMetadata({ params }: Props) {
const { locale, slug } = await params
const post = await fetch(`https://api.taskflow.io/blog/${slug}`).then(r => r.json())
return {
title: locale === 'en' ? post.title : post.titleLocalized[locale],
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `https://taskflow.io/${l}/blog/${slug}`])
)
}
}
}
export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = await params
const t = await getTranslations('blog')
return (
<article>
<h1>{t('title')}</h1>
<p>Locale: {locale} | Slug: {slug}</p>
</article>
)
}
7. RTL Layout Support
(1) Differences Between RTL and LTR
graph TB
subgraph LTR Layout
A1[Logo<br/>Left-aligned] --> A2[Navigation Bar<br/>From left to right]
A2 --> A3[Sidebar<br/>Left-aligned]
A3 --> A4[Content<br/>Keep to the right]
end
subgraph RTL Layout
B1[Logo<br/>Keep to the right] --> B2[Navigation Bar<br/>From right to left]
B2 --> B3[Sidebar<br/>Keep to the right]
B3 --> B4[Content<br/>Left-aligned]
end
style B1 fill:#d4edda
style B2 fill:#d4edda
style B3 fill:#d4edda
| Dimension | LTR (Chinese/English/Japanese) | RTL (Arabic) |
|---|---|---|
| Text Direction | Left-to-Right | Right-to-Left |
| Navigation Bar | Logo on the left → Menu on the right | Logo on the right → Menu on the left |
| Sidebar | Left | Right |
| Arrow icon | → Forward |
← Forward |
| Card margin | margin-left: auto |
margin-right: auto |
| Form Submit Button | Bottom Right | Bottom Left |
(2) Tailwind CSS RTL Support
// app/[locale]/layout.tsx — RTL Testing and CSS Class Name
export default async function LocaleLayout({ children, params }: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const isRtl = locale === 'ar'
return (
<html lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
<body className={isRtl ? 'rtl' : 'ltr'}>
{children}
</body>
</html>
)
}
/* app/globals.css — RTL Basic Styles */
.ltr { direction: ltr; }
.rtl { direction: rtl; }
/* Navigation Bar Mirroring */
.rtl .nav-items { flex-direction: row-reverse; }
.rtl .sidebar { right: 0; left: auto; }
.rtl .main-content { margin-right: 260px; margin-left: 0; }
/* Icon Mirroring */
.rtl .icon-arrow::before { content: '←'; }
.ltr .icon-arrow::before { content: '→'; }
/* Spacing Reversal */
.rtl .ml-auto { margin-right: auto; margin-left: 0; }
.rtl .mr-2 { margin-left: 0.5rem; margin-right: 0; }
▶ Example: RTL-optimized dashboard layout
// components/DashboardLayout.tsx — RTL Compatible Layouts
export default function DashboardLayout({
sidebar,
children,
locale
}: {
sidebar: React.ReactNode
children: React.ReactNode
locale: string
}) {
const isRtl = locale === 'ar'
return (
<div style={{
display: 'flex',
flexDirection: isRtl ? 'row-reverse' : 'row',
minHeight: '100vh'
}}>
{/* Sidebar:LTR On the left,RTL On the right */}
<aside style={{
width: 260,
borderRight: isRtl ? 'none' : '1px solid #e5e7eb',
borderLeft: isRtl ? '1px solid #e5e7eb' : 'none'
}}>
{sidebar}
</aside>
{/* Main Content Area */}
<main style={{
flex: 1,
padding: '2rem',
textAlign: isRtl ? 'right' : 'left'
}}>
{children}
</main>
</div>
)
}
direction: rtl; you also need to mirror all flex directions, margins/padding, icon arrows, and text alignment.
8. Complete Example: Official Website in Four Languages + RTL Dashboard
// app/[locale]/layout.tsx — Internationalization Root Layout
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { routing } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import LocaleSwitcher from '@/components/LocaleSwitcher'
import type { Metadata } from 'next'
type Props = { children: React.ReactNode; params: Promise<{ locale: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params
return {
title: 'TaskFlow',
alternates: {
canonical: `https://taskflow.io/${locale}`,
languages: Object.fromEntries(
routing.locales.map((l) => [l, `https://taskflow.io/${l}`])
)
}
}
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params
if (!routing.locales.includes(locale as any)) notFound()
const messages = await getMessages()
const isRtl = locale === 'ar'
return (
<html lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
<head>
{/* RTL Loading... RTL CSS */}
{isRtl && <link rel="stylesheet" href="/rtl.css" />}
</head>
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
<NextIntlClientProvider messages={messages}>
<nav style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem 2rem',
borderBottom: '1px solid #e5e7eb',
flexDirection: isRtl ? 'row-reverse' : 'row'
}}>
<strong>TaskFlow</strong>
<LocaleSwitcher />
</nav>
<main style={{ padding: '2rem' }}>{children}</main>
</NextIntlClientProvider>
</body>
</html>
)
}
// app/[locale]/dashboard/page.tsx — RTL Dashboard
import { getTranslations } from 'next-intl/server'
type Props = { params: Promise<{ locale: string }> }
export default async function DashboardPage({ params }: Props) {
const { locale } = await params
const t = await getTranslations('dashboard')
const isRtl = locale === 'ar'
return (
<div>
<h1 style={{ textAlign: isRtl ? 'right' : 'left' }}>{t('welcome', { name: 'Alice' })}</h1>
<div style={{
display: 'flex',
gap: '1rem',
flexDirection: isRtl ? 'row-reverse' : 'row'
}}>
<div style={{ flex: 1, padding: '1.5rem', background: '#f0f9ff', borderRadius: 8 }}>
<h3>{t('stats.members', { count: 12 })}</h3>
</div>
<div style={{ flex: 1, padding: '1.5rem', background: '#fef3c7', borderRadius: 8 }}>
<h3>{t('stats.projects', { count: 45 })}</h3>
</div>
<div style={{ flex: 1, padding: '1.5rem', background: '#ecfdf5', borderRadius: 8 }}>
<h3>{t('stats.tasks', { count: 230 })}</h3>
</div>
</div>
<h2 style={{ textAlign: isRtl ? 'right' : 'left' }}>{t('activity')}</h2>
<div style={{
padding: '1rem',
border: '1px solid #e5e7eb',
borderRadius: 8,
textAlign: isRtl ? 'right' : 'left'
}}>
<p>Alice The project was created "Website Redesign"</p>
<p>Bob Done Task #47</p>
<p>Charlie Joined the organization</p>
</div>
</div>
)
}
# Output Results(Chinese):
TaskFlow
[Select a language: Chinese ▼]
Welcome back,Alice
[12 Famous members] [45 projects] [230 A task]
Recent Events
Alice The project was created "Website Redesign"
Bob Done Task #47
Charlie Joined the organization
# Output Results(Arabic):
TaskFlow
[اختيار اللغة: العربية ▼]
مرحباً بعودتك، Alice
[12 أعضاء] [45 مشروعاً] [230 مهمة]
النشاطات الأخيرة
أليس قامت بإنشاء مشروع "إعادة تصميم الموقع"
بوب أكمل المهمة رقم 47
تشارلي انضم إلى المنظمة
❓ FAQ
next-intl or next-international?next-intl has a larger community (5k⭐) and more features (plural, selection, number formatting, and RTL detection). next-international is more lightweight. This tutorial uses next-intl.rewrite keeps the content of the browser’s address bar unchanged while internally mapping to /{locale}/path, which is better for SEO and user experience. redirect changes the URL, which may break links shared by users.direction: rtl, you must mirror the flex direction (flex-direction: row-reverse), adjust the direction of margins and padding, flip arrow icons, and use text-align: start/end instead of left/right. It is recommended to handle this uniformly using the [dir="rtl"] selector in globals.css.generateStaticParams generate a separate HTML file for each language?/zh/blog/post-1, /en/blog/post-1, and /ar/blog/post-1 are three separate HTML files. Each file contains only the content for its corresponding language, ensuring the fastest loading speed.{name} to insert variables, {count, plural, =0 {none} one {# task} other {# tasks}} to handle plurals. The t() and useTranslations() hooks in next-intl automatically parse these templates.📖 Summary
next-intlProvides a comprehensive internationalization solution: Middleware (language detection) + dictionary management + RTL support- Middleware checks in order of priority: Cookie → Accept-Language → default language; the result is written to a cookie for persistence
- The dictionary uses nested JSON structures for management and supports ICU MessageFormat for plurals, variables, and selections.
generateStaticParamsPre-generates separate HTML for each language; zero runtime overhead in SSG mode- RTL support requires the
dirproperty + CSS mirroring (flex direction / margin / padding / icons) - Dates, numbers, and currency are automatically formatted using the JavaScript
IntlAPI, without the need for additional libraries
📝 Exercises
-
Basic Exercise (⭐): Install
next-intlin an existing project, configure both Chinese and English dictionaries, implement a language-switching component, and uset()to render the navigation bar text on the page. -
Advanced Exercise (⭐⭐): Implement a complete multilingual SSG blog:
generateStaticParamsPre-generate article pages for four languages; have each article load the corresponding language dictionary during the build process; and verify the structure of the generated HTML files. -
Challenge (⭐⭐⭐): Build a dashboard page that supports RTL: Configure it for both Arabic and English, and use the
rtl:prefix andflex-directionmirroring in Tailwind CSS to implement full layout adaptation for the sidebar, navigation, and statistics cards. Verify that all elements are correctly mirrored underdir="rtl".



