Setting up i18n framework (next-intl) for Next.js
next-intl is de-facto standard for i18n in Next.js App Router. Integrates with server components (RSC), supports Server Actions, works with static generation and streaming render. Unlike competitors, translations are available in Server Components without client JS.
Installation
npm install next-intl
Version 3.x requires Next.js 13.4+ with App Router.
File structure
messages/
ru.json
en.json
de.json
src/
app/
[locale]/
layout.tsx
page.tsx
catalog/
page.tsx
i18n.ts
middleware.ts
Configuration
// src/i18n.ts
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: 'Europe/Moscow',
now: new Date(),
}))
// src/middleware.ts
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['ru', 'en', 'de', 'uk'],
defaultLocale: 'ru',
localePrefix: 'as-needed', // /ru/ omitted for defaultLocale
})
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}
// next.config.js
const withNextIntl = require('next-intl/plugin')('./src/i18n.ts')
module.exports = withNextIntl({
// rest of configuration
})
Translation files
// messages/ru.json
{
"nav": {
"catalog": "Каталог",
"cart": "Корзина",
"account": "Личный кабинет"
},
"catalog": {
"title": "Каталог товаров",
"items": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товаров}}",
"filter": "Фильтры",
"sort": "Сортировка",
"sort_price_asc": "Дешевле",
"sort_price_desc": "Дороже",
"empty": "Товары не найдены"
},
"product": {
"addToCart": "В корзину",
"buyNow": "Купить сейчас",
"inStock": "В наличии",
"outOfStock": "Нет в наличии"
}
}
Layout with provider
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
const locales = ['ru', 'en', 'de', 'uk']
export async function generateStaticParams() {
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'meta' })
return {
title: t('title'),
description: t('description'),
}
}
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode
params: { locale: string }
}) {
if (!locales.includes(locale)) notFound()
// Pass messages to Client Components
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
Using in Server Components
// src/app/[locale]/catalog/page.tsx
import { getTranslations } from 'next-intl/server'
export default async function CatalogPage({
params: { locale },
searchParams,
}: {
params: { locale: string }
searchParams: { page?: string }
}) {
const t = await getTranslations('catalog')
const products = await fetchProducts({ locale, page: Number(searchParams.page ?? 1) })
return (
<main>
<h1>{t('title')}</h1>
<p>{t('items', { count: products.total })}</p>
{/* ... */}
</main>
)
}
Using in Client Components
'use client'
import { useTranslations, useLocale, useFormatter } from 'next-intl'
function ProductCard({ product }: { product: Product }) {
const t = useTranslations('product')
const locale = useLocale()
const format = useFormatter()
return (
<div>
<h2>{product.title}</h2>
<p>
{format.number(product.price, {
style: 'currency',
currency: locale === 'ru' ? 'RUB' : 'USD',
maximumFractionDigits: 0,
})}
</p>
<span>{product.inStock ? t('inStock') : t('outOfStock')}</span>
<button>{t('addToCart')}</button>
</div>
)
}
Localized routes (pathnames)
// src/navigation.ts
import { createLocalizedPathnamesNavigation } from 'next-intl/navigation'
export const { Link, redirect, usePathname, useRouter } =
createLocalizedPathnamesNavigation({
locales: ['ru', 'en', 'de'],
pathnames: {
'/': '/',
'/catalog': { ru: '/catalog', en: '/catalog', de: '/katalog' },
'/catalog/[slug]': {
ru: '/catalog/[slug]',
en: '/catalog/[slug]',
de: '/katalog/[slug]',
},
'/checkout': { ru: '/checkout', en: '/checkout', de: '/kasse' },
},
})
// Use localized Link instead of next/link
import { Link } from '@/navigation'
<Link href="/catalog">Каталог</Link>
// For ru: /catalog
// For de: /de/katalog
Static generation with translations
// Generate static pages for all languages
export async function generateStaticParams() {
const products = await fetchAllProductSlugs()
return ['ru', 'en', 'de'].flatMap(locale =>
products.map(product => ({
locale,
slug: product.slugs[locale],
}))
)
}
TypeScript: auto-completion for translation keys
// global.d.ts
import ru from './messages/ru.json'
declare module 'next-intl' {
interface AppConfig {
Messages: typeof ru
}
}
Now t('nonexistent.key') is a TypeScript error.
Timeframe
Basic next-intl setup with 2–3 languages — 1 day. With localized pathnames, static generation for all language versions and TypeScript key typing — 2–3 days.







