Migrating from i18next to next-intl in a Scalable Multilingual Next.js 15 App

A practical migration story for a growing EdTech startup in the MENA region
Introduction
As the lead frontend engineer for a rapidly scaling EdTech platform serving the MENA region (where Arabic, French, and English are dominant), I recently undertook a critical migration from i18next to next-intl. Here's why this decision significantly improved our internationalization strategy and how you can implement it effectively.
Why We Migrated from i18next
We initially chose i18next for its maturity and flexibility, but we encountered several pain points as our platform grew:
Performance bottlenecks with client-side translations
Complex routing in our Next.js 15 App Router architecture
Suboptimal TypeScript support requiring excessive type assertions
Cumbersome SSR implementation
After evaluating alternatives, next-intl emerged as the clear winner with:
Native Next.js App Router integration
Automatic static optimization
Type-safe translations out of the box
Simplified server component support
Built-in locale-aware routing
Strategic Decision: Route Structure
There are three main i18n Routing Paths:
- Locale-Prefixed Routes (
/en/about
)
Best for: Public marketing sites needing maximum SEO
Our hesitation: Felt clunky for our authenticated app experience
- Cookie-Based Detection (Clean URLs)
Best for: Web apps where users stay logged in
Our choice: Aligned perfectly with our EdTech platform's workflow
- Hybrid Approach (Mixed strategy)
Best for: Apps with distinct public/private sections
Why we passed: Added more complexity than value for our case
Why Cookie-Free URLs Won for Us:
- Better UX for Daily Users
No distracting
/fr
prefixes in dashboard URLsSmooth transitions between languages without URL changes
- Simpler Codebase
No need to handle locale in every route segment
Easier route group management (
(auth)
,(dashboard)
)
How We Solved the Challenges:
- SEO Optimization
Added server-side
hreflang
injectionCreated a
/language-selector
component for crawlersImplemented strategic sitemap localization
- Locale Persistence
Check user's saved preference
Fallback to last-used cookie
Final fallback: browser language detection
When We'd Reconsider:
If we add a public marketing site, we'll implement hybrid routing - but for our app's core experience, cookie-based routing continues to deliver the best balance of cleanliness and functionality.
Step-by-Step Installation Guide
Installation
npm install next-intl
Next.js Configuration ( next.config.js )
import {NextConfig} from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
const config: NextConfig = {};
export default withNextIntl(config);
Internationalization Setup
// i18n/config.ts
export const locales = ['en', 'fr', 'ar'] as const;
export const defaultLocale = 'ar' satisfies Locale;
export type Locale = (typeof locales)[number];
// i18n/request.ts
import {getRequestConfig} from 'next-intl/server';
import {getUserLocale} from '../services/locale';
export default getRequestConfig(async () => {
const locale = await getUserLocale();
return {
locale,
messages: (await import(`@/messages/${locale}.json`)).default
};
});
// services/locale.ts
'use server';
import {cookies} from 'next/headers';
import {Locale, defaultLocale} from '@/i18n/config';
// In this example the locale is read from a cookie. You could alternatively
// also read it from a database, backend service, or any other source.
const COOKIE_NAME = 'NEXT_LOCALE';
export async function getUserLocale() {
return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale;
}
export async function setUserLocale(locale: Locale) {
(await cookies()).set(COOKIE_NAME, locale);
}
Layout Setup
// app/layout.tsx
export default function RootLayout({
children,
params: {locale}
}: {
children: React.ReactNode;
params: {locale: Locale};
}) {
return (
<html
lang={locale}
dir={locale === 'ar' ? 'rtl' : 'ltr'}
className={locale === 'ar' ? 'rtl' : ''}
>
<body>{children}</body>
</html>
);
}
Change locale component ( type-safe)
'use client';
import * as Select from '@radix-ui/react-select';
import clsx from 'clsx';
import {useTransition} from 'react';
import {Locale} from '@/i18n/config';
import {setUserLocale} from '@/services/locale';
import { Check, Languages } from 'lucide-react';
type Props = {
defaultValue: string;
items: Array<{value: string; label: string}>;
label: string;
};
export default function LocaleSwitcher({
defaultValue,
items,
label
}: Props) {
const [isPending, startTransition] = useTransition();
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
return (
<div className="relative">
<Select.Root defaultValue={defaultValue} onValueChange={onChange}>
<Select.Trigger
aria-label={label}
className={clsx(
'rounded-sm p-2 transition-colors hover:bg-slate-200',
isPending && 'pointer-events-none opacity-60'
)}
>
<Select.Icon>
<Languages className="h-6 w-6 text-slate-600 transition-colors group-hover:text-slate-900" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
align="end"
className="min-w-[8rem] overflow-hidden rounded-sm bg-white py-1 shadow-md"
position="popper"
>
<Select.Viewport>
{items.map((item) => (
<Select.Item
key={item.value}
className="flex cursor-default items-center px-3 py-2 text-base data-[highlighted]:bg-slate-100"
value={item.value}
>
<div className="mr-2 w-[1rem]">
{item.value === defaultValue && (
<Check className="h-5 w-5 text-slate-600" />
)}
</div>
<span className="text-slate-900">{item.label}</span>
</Select.Item>
))}
</Select.Viewport>
<Select.Arrow className="fill-white text-white" />
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
);
}
Messages folder ( en.json, ar.json … )
{
"LocaleSwitcher": {
"label": "Change language",
"en": "English",
"fr": "French",
"ar": "Arabic"
},
"HomePage": {
"title": "Welcome to my blog",
"subtitle": "This is a simple blog setup with next-intl."
}
}
Results & Metrics
Post-migration, we observed:
Reduction in translation-related bugs
Improvement in Lighthouse i18n score
Faster page loads for Arabic content
Simplified developer onboarding
Key Takeaways
Framework-Specific Architecture
next-intl is designed specifically for Next.js, while i18next maintains framework-agnostic compatibility.Type System Differences
next-intl provides built-in type safety for translations, whereas i18next required manual type declarations in our implementation.Execution Location
next-intl handles translations primarily in Server Components, while our i18next implementation ran mostly client-side.Routing Approach
We used cookie-persisted locale preferences combined with manual hreflang tags, achieving clean URLs, SEO preservation, user-specific language persistence across sessions.
Let's Connect
Enjoyed this breakdown? Follow me on LinkedIn for more software engineering insights.
What's your experience with internationalization? Share in the comments!
Subscribe to my newsletter
Read articles from ABADA Mahmoud directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

ABADA Mahmoud
ABADA Mahmoud
Software engineer building scalable AI-powered apps with Next.js, Bun, TypeScript, and cloud-native tech. Writing to learn, teach, and lead.