Skip to main content
Back to Blog
7 min read

next-intl vs next-i18next: the switching cost I actually paid

I migrated a production Next.js site from next-i18next to next-intl. Here's why, how long it took, what broke, and whether I'd do it again.

next.jsi18nperformance

This site — dimaver6.com — started on next-i18next. I migrated it to next-intl in a single weekend. The migration was straightforward. The decision to migrate was not.

This post covers why I switched, the exact migration steps, what broke along the way, and the honest tradeoffs between the two libraries in 2026.

Why I switched

Three reasons, in order of importance:

1. App Router support

next-i18next was built for Pages Router. When Next.js shifted to App Router, next-i18next added support, but it felt bolted on. Server Components needed workarounds. The serverSideTranslations pattern didn't map cleanly to generateMetadata or layout-level data fetching.

next-intl was designed for App Router from the start. getTranslations() works naturally in Server Components, layouts, and generateMetadata. No workarounds, no prop drilling of translation functions through the component tree.

2. Type safety

next-intl supports typed translation keys out of the box. If I reference t('Navigation.bog') instead of t('Navigation.blog'), TypeScript catches it at build time. next-i18next required extra setup (custom type declarations, react-i18next augmentation) to get the same level of type safety, and even then it was partial.

For a trilingual site where a missing translation key means a broken UI in one locale, type safety isn't a nice-to-have — it's a guard against silent failures.

3. Routing integration

next-intl includes a routing layer (createNavigation, createMiddleware) that handles locale detection, prefix-based routing, and hreflang link generation. With next-i18next, I was managing locale routing manually — a custom middleware, manual hreflang tags in the head, and a separate locale detection script.

next-intl consolidated 4 files of custom routing code into 2 configuration files.

The migration

Step 1: Install and configure (30 minutes)

npm install next-intl
npm uninstall next-i18next i18next react-i18next

Created i18n/config.ts with locale definitions, i18n/request.ts for the server-side configuration, and i18n/navigation.ts for the locale-aware Link, redirect, and usePathname.

Step 2: Translation file format (0 minutes)

Both libraries use JSON files with the same structure. My messages/en.json, messages/ru.json, and messages/uk.json files worked as-is. No reformatting needed.

This was the biggest time-saver. If the translation format had been different, the migration would have been 2x longer.

Step 3: Replace the middleware (45 minutes)

next-i18next's middleware handled locale detection and rewriting. Replaced it with next-intl's createMiddleware:

// Before (next-i18next): custom middleware, ~60 lines
// After (next-intl):
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)
export const config = { matcher: ['/', '/(en|ru|uk)/:path*'] }

60 lines of custom middleware → 6 lines.

Step 4: Update Server Components (2 hours)

The biggest chunk. Every Server Component that used translations needed updating:

// Before (next-i18next)
import { useTranslation } from 'next-i18next';
// ... had to use serverSideTranslations in getStaticProps

// After (next-intl)
import { getTranslations } from 'next-intl/server';

export default async function MyPage() {
  const t = await getTranslations('MyNamespace');
  return <h1>{t('title')}</h1>;
}

The pattern change: useTranslation (client hook) → getTranslations (async server function). This affected ~25 components. Most were mechanical find-and-replace. A few needed restructuring because they were Client Components that should have been Server Components.

Step 5: Update Client Components (1 hour)

Client Components that need translations use useTranslations (note the plural):

// Before
import { useTranslation } from 'react-i18next'
const { t } = useTranslation('common')

// After
import { useTranslations } from 'next-intl'
const t = useTranslations('Common')

~10 Client Components needed this change. Mechanical replacement.

next-intl provides a locale-aware Link that automatically prefixes the locale:

import { Link } from '@/i18n/navigation';

// Renders /en/blog, /ru/blog, /uk/blog depending on current locale
<Link href="/blog">Blog</Link>

With next-i18next, I was using Next.js's built-in Link and manually constructing locale-prefixed URLs. Replaced ~15 Link usages.

Step 7: Update metadata generation (30 minutes)

// Before: manual hreflang in layout
// After: next-intl handles it via generateMetadata

export async function generateMetadata({ params }: Props) {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'Meta' })
  return {
    title: t('title'),
    alternates: {
      languages: {
        en: '/en',
        ru: '/ru',
        uk: '/uk',
      },
    },
  }
}

Total migration time: ~5 hours

Spread across a Saturday. No downtime — I developed on a branch, tested all three locales, and merged.

What broke

1. Namespace casing

next-i18next conventionally uses lowercase namespaces (common, navigation). next-intl doesn't enforce a convention, but I switched to PascalCase (Common, Navigation) to match component names. This meant updating every t() call's namespace reference. I missed two, which caused blank strings in the Russian locale. Caught by TypeScript after I added the type augmentation.

2. Interpolation syntax

Both use {variable} syntax, but next-i18next also supported {{variable}} (double curly braces from i18next legacy). I had three translation strings using double braces. They rendered as literal {variable} text instead of interpolated values. Quick regex fix.

3. Plural rules

next-i18next uses i18next's plural format: key_one, key_other. next-intl uses ICU format: {count, plural, one {# item} other {# items}}. I had 4 translation strings with plurals. Converting them took 15 minutes but required understanding ICU syntax.

4. The language selector

My language selector component used i18next.changeLanguage() to switch locales client-side. next-intl handles locale switching via navigation (redirect to the same path with a different locale prefix). The component needed a rewrite — from calling a function to rendering locale-prefixed links.

The honest comparison

next-intlnext-i18next
App Router supportNative, first-classAdded later, works but feels bolted on
Server ComponentsgetTranslations() — clean async patternRequires workarounds, prop passing
Type safetyBuilt-in with TypeScript module augmentationPossible but requires manual setup
RoutingIncluded (middleware, Link, redirect)BYO or use next-i18next's limited routing
Translation formatJSON (same as i18next)JSON (i18next format)
PluralsICU MessageFormati18next format (_one, _other)
EcosystemSmaller, Next.js-specificMassive (i18next ecosystem)
Learning curveLow if you know Next.jsLow if you know i18next
Migration from i18next~5 hours for a medium siteN/A

When to pick next-intl

When to pick next-i18next

Would I migrate again?

Yes. The migration was 5 hours and the DX improvement is real — type-safe keys, cleaner Server Component patterns, and no custom routing code. For a trilingual site where I add pages frequently, catching a typo'd translation key at build time instead of in a production screenshot is worth the migration cost several times over.

If you're starting a new Next.js project with i18n: start with next-intl. If you're on next-i18next and it's working fine: migrate when you move to App Router, not before. I wrote about the full i18n architecture including routing, SEO, and RSC patterns.


Building a multilingual Next.js app and not sure which i18n approach fits? Let's talk — I've shipped both and can save you the wrong-library tax.