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

ABADA MahmoudABADA Mahmoud
5 min read

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:

  1. Locale-Prefixed Routes (/en/about)
  • Best for: Public marketing sites needing maximum SEO

  • Our hesitation: Felt clunky for our authenticated app experience

  1. Cookie-Based Detection (Clean URLs)
  • Best for: Web apps where users stay logged in

  • Our choice: Aligned perfectly with our EdTech platform's workflow

  1. 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:

  1. Better UX for Daily Users
  • No distracting /fr prefixes in dashboard URLs

  • Smooth transitions between languages without URL changes

  1. Simpler Codebase
  • No need to handle locale in every route segment

  • Easier route group management ((auth), (dashboard))

How We Solved the Challenges:

  1. SEO Optimization
  • Added server-side hreflang injection

  • Created a /language-selector component for crawlers

  • Implemented strategic sitemap localization

  1. 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

  1. Installation

npm install next-intl
  1. 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);
  1. 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);
}
  1. 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>
  );
}
  1. 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>
  );
}
  1. 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

  1. Framework-Specific Architecture
    next-intl is designed specifically for Next.js, while i18next maintains framework-agnostic compatibility.

  2. Type System Differences
    next-intl provides built-in type safety for translations, whereas i18next required manual type declarations in our implementation.

  3. Execution Location
    next-intl handles translations primarily in Server Components, while our i18next implementation ran mostly client-side.

  4. 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!

0
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.