Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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.

The Server Components split is the single largest source of confusion. In the Pages Router era, every i18n library could safely assume your translations were available everywhere through a single React context. The App Router changed that: Server Components render before any client context exists, so useTranslations() only works in code marked 'use client'. Anywhere else, you need the async getTranslations(). next-intl tries to make both look similar, but they go through different code paths — one reads from the React tree, the other reads from request-scoped storage that createNextIntlPlugin() sets up.

The other subtle behavior is how locale detection cascades. By default the middleware reads the Accept-Language header, but it can be overridden by an explicit locale prefix in the URL, by the NEXT_LOCALE cookie, or by the localePrefix setting in routing.ts. When users complain that the wrong language is showing, the cause is almost always that the cookie says one thing and the URL says another. Setting localePrefix: 'always' and reading the locale strictly from the URL eliminates this class of bug at the cost of slightly less convenient URLs for the default locale.

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;
}

next-intl vs react-intl, next-translate, Lingui, Paraglide — When Each Fits

The Next.js i18n ecosystem has five reasonable choices, and the right one depends on whether you care about static rendering, bundle size, or message format portability.

next-intl. Built specifically for the App Router. Uses ICU MessageFormat (the same syntax used by react-intl). Works in both Server and Client Components through two parallel APIs (getTranslations async vs useTranslations hook). Best fit: App Router projects that need plural rules, gendered translations, and full ICU features.

react-intl (FormatJS). The framework-agnostic veteran. Same ICU MessageFormat, but no Next.js-specific helpers — you wire up the provider, locale detection, and message loading yourself. Bundle size is larger because the formatter includes CLDR data for every locale by default (you can prune this with formatjs-cli). Best fit: monorepos where the same translation pipeline serves a Next.js app, a React Native app, and a backend service.

next-translate. Lighter alternative focused on simplicity. Uses a custom interpolation syntax ({{count}}) rather than ICU, which is easier to write but lacks plural and select forms out of the box. Translations load per-page through a next.config.js mapping, keeping initial bundles small. Best fit: Pages Router projects or apps that want smaller bundles and don’t need ICU complexity.

Lingui. Uses a Babel/SWC macro that extracts messages from a <Trans> component or t template literal, then compiles them at build time into compact lookups. Supports ICU. The tooling is the strongest of the five — extraction, catalog management, and tree-shakable runtime. Best fit: large codebases where translation extraction needs to be automatic and bundle size matters.

Paraglide JS. Compile-time approach: every message becomes a tree-shakable function in your bundle. Locales you don’t ship don’t appear in the build at all. No runtime parser, no provider, no context — just imports. Trade-off: no ICU plural/select syntax, you express plurals in JavaScript. Best fit: edge-rendered apps and projects that prize zero-runtime cost over rich formatting features.

The ICU MessageFormat distinction matters more than people expect. ICU is the only format that gives you correct plural rules in languages with multiple plural forms (Russian, Arabic, Polish). If your audience includes those languages, next-intl, react-intl, or lingui are your only real choices. For tooling-heavy workflows, see Lingui not working and Paraglide not working for setup-specific issues; for the older sibling, see i18next not working.

The static-vs-dynamic locale choice also drives the decision. If your locales are known at build time and you want them statically generated, all five can do this — but next-intl and Paraglide do it most ergonomically because they expect static locale lists. If locales come from a CMS or backend at runtime, react-intl gives you the most flexibility because it doesn’t bake assumptions about Next.js into its API.

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.

generateStaticParams outputs every page in every locale, ballooning the build — that’s expected if you call it without filtering, because next-intl wants a route per locale. If your build time becomes painful, switch to on-demand revalidation (revalidateTag) for content that changes rarely and let Next.js render those routes at request time. Confirm dynamic = 'force-static' is set only on routes you genuinely want pre-rendered for every locale.

Server actions can’t see the user’s locale — server actions run in their own request context. If you call getLocale() inside an action without a locale segment in the request, you get the default. Pass the locale explicitly from the calling component or read it from a cookie inside the action. This is also the right place to invalidate per-locale caches when content changes.

Date formatting differs between the server and the clientIntl.DateTimeFormat uses the runtime’s time zone, which is UTC on Vercel’s edge by default and the user’s local zone in the browser. Pass an explicit timeZone option to format.dateTime() to lock the output, or store dates as UTC strings and only format on the client.

For related Next.js issues that interact with the middleware-based locale routing, see Next.js middleware not running.

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