Fix: next-intl Not Working — Translations Missing, Locale Not Detected, or Middleware Redirect Loop
Quick Answer
How to fix next-intl issues — App Router setup with middleware, useTranslations in server and client components, locale detection, pluralization, number and date formatting, and routing configuration.
The Problem
useTranslations throws an error in a Server Component:
// app/[locale]/page.tsx
const t = useTranslations('Home');
// Error: No intl messages found for locale "en". Have you configured the provider?Or the middleware creates an infinite redirect loop:
GET /en → 302 /en → 302 /en → ...Or pluralization doesn’t work:
t('items', { count: 5 }); // Returns 'items' instead of '5 items'Or after navigating to a different locale, the URL updates but translations stay in the old language:
/en/about → navigates to → /fr/about
// URL shows /fr/about but text is still in EnglishWhy This Happens
next-intl integrates deeply with Next.js App Router routing and React Server Components:
NextIntlClientProvidermust be in a Client Component context —useTranslationsreads from context. In Server Components, use thegetTranslations()async function instead. Mixing them up is the most common error.- Middleware path configuration must exclude static assets — if middleware matches
/_next/staticor/favicon.ico, it redirects those requests to a localized path, causing 404s or loops. - Message files must be loaded per-locale — next-intl doesn’t automatically find your translation files. You configure a
getRequestConfigfunction that returns the right messages for the requested locale. - Pluralization uses ICU message syntax —
{count, plural, one {# item} other {# items}}is the correct format. A plain{count}interpolation doesn’t enable plural forms.
Fix 1: Set Up next-intl with App Router
npm install next-intlDirectory structure:
app/
├── [locale]/ # Dynamic locale segment
│ ├── layout.tsx # Provides locale and messages
│ └── page.tsx
├── globals.css
└── layout.tsx # Root layout (no locale here)
messages/
├── en.json
├── fr.json
└── ja.json
middleware.ts # Locale detection and routing
next.config.ts
i18n/
└── request.ts # next-intl config// i18n/request.ts — configure next-intl
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale — fallback to default if invalid
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'fr', 'ja'],
defaultLocale: 'en',
// localePrefix: 'always', // Always include prefix (/en/about)
// localePrefix: 'as-needed', // Skip prefix for default locale (/about for en, /fr/about for fr)
});// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match all paths EXCEPT:
// - _next (Next.js internals)
// - Static files with extensions
matcher: ['/((?!_next|_vercel|.*\\..*).*)'],
};// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
export default withNextIntl({
// your Next.js config
});Fix 2: Use Translations in Server and Client Components
// app/[locale]/layout.tsx — provide locale for client components
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Get messages for client components
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}In Server Components — use getTranslations():
// app/[locale]/page.tsx (Server Component)
import { getTranslations } from 'next-intl/server';
export default async function HomePage() {
// Async — uses the locale from the request
const t = await getTranslations('Home');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
);
}
// Generate metadata with translations
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
return {
title: t('homeTitle'),
description: t('homeDescription'),
};
}In Client Components — use useTranslations():
// components/SearchBar.tsx
'use client';
import { useTranslations } from 'next-intl';
export function SearchBar() {
const t = useTranslations('Search');
return (
<input
type="search"
placeholder={t('placeholder')}
aria-label={t('ariaLabel')}
/>
);
}Fix 3: Structure Translation Files
// messages/en.json
{
"Home": {
"title": "Welcome to My App",
"description": "The best app ever built."
},
"Nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"Common": {
"loading": "Loading...",
"error": "Something went wrong",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Are you sure you want to delete {name}?"
},
"Items": {
"count": "{count, plural, =0 {No items} one {# item} other {# items}}",
"addItem": "Add item",
"empty": "No items found. {link}",
"addFirstLink": "Add your first item"
},
"Date": {
"posted": "Posted on {date}"
}
}// messages/fr.json
{
"Home": {
"title": "Bienvenue dans Mon Application",
"description": "La meilleure application jamais créée."
},
"Items": {
"count": "{count, plural, =0 {Aucun élément} one {# élément} other {# éléments}}"
}
}Use translations in components:
import { useTranslations } from 'next-intl';
import Link from 'next/link';
function ItemCount({ count }: { count: number }) {
const t = useTranslations('Items');
return (
<div>
{/* Pluralization */}
<p>{t('count', { count })}</p>
{/* Variable interpolation */}
<p>{t('confirm', { name: 'Alice' })}</p>
{/* Rich text — embed React elements */}
<p>{t.rich('empty', {
link: (chunks) => (
<Link href="/items/new">{chunks}</Link>
),
})}</p>
</div>
);
}Fix 4: Format Dates and Numbers
'use client';
import { useFormatter, useNow } from 'next-intl';
function FormattedContent({ price, date, count }: {
price: number;
date: Date;
count: number;
}) {
const format = useFormatter();
const now = useNow({ updateInterval: 1000 * 60 }); // Update every minute
return (
<div>
{/* Numbers */}
<p>{format.number(price, { style: 'currency', currency: 'USD' })}</p>
{/* en: $1,234.56, fr: 1 234,56 $US */}
<p>{format.number(0.75, { style: 'percent' })}</p>
{/* en: 75%, de: 75 % */}
{/* Dates */}
<p>{format.dateTime(date, { dateStyle: 'full', timeStyle: 'short' })}</p>
{/* en: Friday, March 15, 2024 at 10:30 AM */}
<p>{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}</p>
{/* Relative time */}
<p>{format.relativeTime(date, now)}</p>
{/* '5 minutes ago', '2 hours ago', etc. — localized */}
{/* List formatting */}
<p>{format.list(['Alice', 'Bob', 'Charlie'], { type: 'conjunction' })}</p>
{/* en: Alice, Bob, and Charlie */}
</div>
);
}In Server Components:
import { getFormatter } from 'next-intl/server';
export default async function PricePage() {
const format = await getFormatter();
const price = format.number(1234.56, { style: 'currency', currency: 'EUR' });
return <p>{price}</p>;
}Fix 5: Locale Switching and Navigation
// components/LocaleSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { routing } from '@/i18n/routing';
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function switchLocale(newLocale: string) {
// Replace locale segment in current path
const segments = pathname.split('/');
segments[1] = newLocale; // Replace locale segment
router.push(segments.join('/'));
}
return (
<div>
{routing.locales.map((loc) => (
<button
key={loc}
onClick={() => switchLocale(loc)}
disabled={loc === locale}
>
{loc.toUpperCase()}
</button>
))}
</div>
);
}
// Or use next-intl's Link for locale-aware navigation
import { Link } from '@/i18n/navigation'; // Generated by next-intl
<Link href="/about">About</Link>
// Automatically prefixes with current localeGenerate @/i18n/navigation:
// i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);Fix 6: TypeScript Type Safety for Translations
// Generate types from your messages
// Add to package.json scripts:
// "postinstall": "next-intl-typegen ./messages/en.json"
// Or configure in next.config.ts
// The generated types prevent typos in translation keys
// Using typed translations (v3.x)
import { useTranslations } from 'next-intl';
import type messages from '../messages/en.json';
// Type-safe access — TypeScript knows every valid key
const t = useTranslations('Home');
t('title'); // ✓
t('nonexistent'); // ✗ TypeScript error
// Declare global type augmentation
declare global {
type IntlMessages = typeof messages;
}Still Not Working?
Redirect loop in middleware — the matcher in middleware.ts is matching too broadly. Static files and API routes must be excluded. The standard pattern is:
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};If using localePrefix: 'as-needed', ensure the default locale paths aren’t being redirected to themselves.
“No intl messages found” — this means getRequestConfig didn’t run, or the messages object is empty. Common causes: (1) next.config.ts doesn’t wrap with withNextIntl(), (2) the path to i18n/request.ts is wrong in createNextIntlPlugin(), (3) the messages JSON import path doesn’t match the actual file.
Client components see wrong locale after navigation — useLocale() reads from the NextIntlClientProvider context. If you’re navigating with a regular <Link> to /fr/page but the provider is re-rendering with the old locale, the issue is that the locale param changed but the provider wasn’t re-initialized. This is usually fixed by ensuring NextIntlClientProvider in the layout takes locale as a prop and messages from getMessages() — both should change with the locale segment.
For related Next.js issues, see Fix: Next.js API Route Not Working and Fix: Next.js CORS Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
How to fix ky HTTP client issues — instance creation, hooks (beforeRequest, afterResponse), retry configuration, timeout handling, JSON parsing, error handling, and migration from fetch or axios.