Skip to content

Fix: next-intl Not Working — Translations Missing, Locale Not Detected, or Middleware Redirect Loop

FixDevs ·

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 English

Why This Happens

next-intl integrates deeply with Next.js App Router routing and React Server Components:

  • NextIntlClientProvider must be in a Client Component contextuseTranslations reads from context. In Server Components, use the getTranslations() async function instead. Mixing them up is the most common error.
  • Middleware path configuration must exclude static assets — if middleware matches /_next/static or /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 getRequestConfig function 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-intl

Directory 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 locale

Generate @/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 navigationuseLocale() 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles