32blogby Studio Mitsu

next-intl Pitfalls: What Caught Me Building a Multilingual Site

Real pitfalls from implementing next-intl v4 on 32blog.com — middleware config, dynamic routes, SEO, and subtle bugs that are hard to spot.

by omitsu10 min read
On this page

The most common next-intl v4 pitfalls are incorrect middleware matchers, mixing Next.js's Link with next-intl's Link, misusing useTranslations in async Server Components, missing locale in generateStaticParams, and incomplete hreflang configuration. Each one silently breaks your multilingual site without obvious errors.

When I added Japanese, English, and Spanish support to 32blog.com using next-intl v4, I ran into pitfalls that weren't covered in the official documentation.

Many of them were the worst kind — "looks like it's working but it's actually broken." This article documents every pitfall I hit, with the fixes.

Pitfall 1: The Middleware Matcher Pattern Is Wrong

This was the first thing I got wrong. An incomplete matcher pattern in middleware means some requests skip locale detection — so visiting / doesn't redirect to /ja/.

❌ Common broken pattern

typescript
// src/middleware.ts
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

This looks reasonable but it passes static files like .png and .svg through the middleware unnecessarily. It's slower in development and can cause edge cases.

✅ Correct pattern

typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: [
    // Root path
    "/",
    // Locale-prefixed paths
    "/(ja|en|es)/:path*",
    // Paths without file extensions (excludes static files)
    "/((?!api|_next|_vercel|.*\\..*).*)"],
};

The .*\\..* part is the key — it excludes paths containing a dot (file extension), which skips static files. This is the recommended pattern from the next-intl middleware docs.

Using Next.js's Link directly breaks locale prefix behavior during navigation.

tsx
// ❌ This doesn't preserve the locale prefix
import Link from "next/link";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      Read article
    </Link>
  );
}

If a Japanese user is on /ja/ and clicks this link, they land on /nextjs/hydration-error-fix — the /ja/ prefix is gone.

tsx
// ✅ next-intl's Link automatically adds the current locale prefix
import { Link } from "@/i18n/routing";

export function ArticleCard({ slug, category }) {
  return (
    <Link href={`/${category}/${slug}`}>
      Read article
    </Link>
  );
}

Link from @/i18n/routing prepends the current locale automatically. Writing /nextjs/hydration-error-fix becomes /ja/nextjs/hydration-error-fix for Japanese users. See the next-intl navigation docs for the full API.

The same applies to useRouter and redirect:

typescript
// ❌ Next.js's hooks lose the locale
import { useRouter } from "next/navigation";
import { redirect } from "next/navigation";

// ✅ Use next-intl's versions instead
import { useRouter, redirect } from "@/i18n/routing";

Pitfall 3: Using useTranslations in an Async Server Component

In next-intl v4, useTranslations actually works in Server Components — but it forces the page into dynamic rendering. If you want your pages to be statically rendered, you need getTranslations instead.

tsx
// ⚠️ Works, but forces dynamic rendering
// No "use client" = Server Component
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("home");
  // This page can never be statically rendered
  return <h1>{t("title")}</h1>;
}
tsx
// ✅ Server Components should use getTranslations (supports static rendering)
import { getTranslations } from "next-intl/server";

export default async function HomePage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  // getTranslations requires await
  const t = await getTranslations({ locale, namespace: "home" });

  return <h1>{t("title")}</h1>;
}

Quick reference (see Server & Client Components docs):

Server ComponentClient Component
TranslationsgetTranslations (await required)useTranslations
Current localegetLocaleuseLocale
All messagesgetMessagesuseMessages

Pitfall 4: Missing Locale in generateStaticParams

In multilingual sites, generateStaticParams must include locale as a parameter.

❌ Generating without locale (non-default locale articles return 404)

tsx
// app/[locale]/[category]/[slug]/page.tsx

// ❌ Only generates for the default locale (ja)
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({
    // locale is missing! Only default locale gets generated
    category: post.category,
    slug: post.slug,
  }));
}

✅ Generate all locale × slug combinations

tsx
// ✅ Generate paths for all locales × all articles
export async function generateStaticParams() {
  const locales = ["ja", "en", "es"] as const;
  const paths = [];

  for (const locale of locales) {
    const posts = await getPostsByLocale(locale);
    for (const post of posts) {
      paths.push({
        locale,
        category: post.category,
        slug: post.slug,
      });
    }
  }

  return paths;
}

If English or Spanish articles are returning 404 after adding them, this is almost always the cause. The Next.js generateStaticParams docs explain how this interacts with dynamic segments.

Pitfall 5: Missing x-default in hreflang

A common SEO mistake: omitting x-default from hreflang config.

tsx
// ❌ No x-default (Google can't determine the default)
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;

  return {
    alternates: {
      languages: {
        "ja": `https://32blog.com/ja/${category}/${slug}`,
        "en": `https://32blog.com/en/${category}/${slug}`,
        "es": `https://32blog.com/es/${category}/${slug}`,
        // x-default is missing!
      },
    },
  };
}
tsx
// ✅ Include x-default to specify the fallback language
export async function generateMetadata({ params }) {
  const { locale, category, slug } = await params;
  const baseUrl = "https://32blog.com";

  return {
    alternates: {
      canonical: `${baseUrl}/${locale}/${category}/${slug}`,
      languages: {
        "ja": `${baseUrl}/ja/${category}/${slug}`,
        "en": `${baseUrl}/en/${category}/${slug}`,
        "es": `${baseUrl}/es/${category}/${slug}`,
        // Where to send users whose language isn't supported
        "x-default": `${baseUrl}/ja/${category}/${slug}`,
      },
    },
  };
}

x-default tells Google where to send users whose language doesn't match any supported locale (for example, French visitors). Without it, Google sees a multilingual site but can't determine the default, which may affect ranking. Google's hreflang specification covers this in detail.

Pitfall 6: notFound Doesn't Get Locale Context

Calling notFound() renders the 404 page — but app/not-found.tsx sits outside [locale], so next-intl translations don't work there.

app/
├── not-found.tsx          ← Outside [locale] → translations won't work
└── [locale]/
    └── not-found.tsx      ← Inside [locale] → translations work
tsx
// ❌ app/not-found.tsx
// Can't use next-intl translations here
export default function NotFound() {
  return (
    <div>
      <h1>404 - Page Not Found</h1>
      {/* English users see this, but so do Japanese users */}
    </div>
  );
}
tsx
// ✅ app/[locale]/not-found.tsx
import { getTranslations } from "next-intl/server";

export default async function NotFound() {
  const t = await getTranslations("notFound");

  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
    </div>
  );
}

Note: not-found.tsx is a Server Component, so use getTranslations instead of useTranslations. app/[locale]/not-found.tsx is used when notFound() is called from within the [locale] segment. 404s that happen before middleware processes the locale (like root-level paths) still use app/not-found.tsx. Keep both files. See the next-intl not-found guide for the recommended setup.

Pitfall 7: Stale Locale in Client Component Memoization

After switching languages, Client Components can hold onto the old locale.

tsx
// ❌ Memoized value doesn't update after locale switch
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [] // ❌ locale not in deps — memo never recalculates
  );

  return <time>{formatted}</time>;
}
tsx
// ✅ Include locale in deps array
"use client";

import { useLocale } from "next-intl";
import { useMemo } from "react";

export function LocalizedDate({ date }: { date: Date }) {
  const locale = useLocale();

  const formatted = useMemo(
    () => date.toLocaleDateString(locale),
    [date, locale] // ✅ locale included
  );

  return <time>{formatted}</time>;
}

When locale is missing from useMemo or useCallback deps, switching languages shows stale formatted data. This one is subtle — the page navigates correctly, but the formatted date or number stays in the old locale's format. The React docs on useMemo explain how dependency arrays control recalculation.

FAQ

Does next-intl work with the Next.js Pages Router?

next-intl v4 supports both Pages Router and App Router. However, the API differs significantly between them. This article covers App Router only. If you're on Pages Router, check the next-intl Pages Router docs.

How do I debug middleware not redirecting to the correct locale?

Add console.log inside your middleware function to confirm it's being called. Check that your matcher pattern isn't excluding the paths you expect to redirect. You can also check the x-next-intl-locale response header — if it's missing, middleware didn't process the request.

Can I use next-intl with Turbopack?

Yes. next-intl v4 works with Turbopack (the default bundler in Next.js 16). No special configuration is needed. If you encounter issues, make sure you're on the latest next-intl version.

Use the built-in no-restricted-imports rule in your ESLint config:

json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "next/link",
        "message": "Use Link from '@/i18n/routing' instead."
      }, {
        "name": "next/navigation",
        "importNames": ["useRouter", "redirect"],
        "message": "Use from '@/i18n/routing' instead."
      }]
    }]
  }
}

Does useTranslations throw an error in Server Components?

No, not in next-intl v4. useTranslations works in Server Components, but it forces the page into dynamic rendering. For statically rendered pages, use getTranslations from next-intl/server instead. See Server & Client Components in the docs.

How should I handle locale detection for first-time visitors?

next-intl's middleware automatically detects the locale from the Accept-Language header, cookies, or your configured default. You can customize the detection strategy in the routing configuration. On 32blog, we set Japanese as the default and let the middleware redirect based on the browser's language preference.

What's the difference between next-intl and next-i18next?

next-i18next was popular for Pages Router but hasn't fully adopted App Router patterns. next-intl was built for App Router from the start, supports React Server Components natively, and has active maintenance (v4.8.3 as of March 2026). For new App Router projects, next-intl is the recommended choice.

Wrapping Up

Here's every pitfall summarized:

PitfallSymptomFix
Incomplete matcherRoot redirect doesn't workUse the correct three-part matcher pattern
Next.js Link instead of next-intl LinkLocale prefix disappears on navigationUse Link from @/i18n/routing
useTranslations in async Server ComponentPage forced into dynamic renderingUse getTranslations (with await)
No locale in generateStaticParamsNon-default locale returns 404Generate locale × slug combinations
Missing x-defaultIncomplete hreflang SEOAdd x-default to alternates.languages
not-found.tsx outside [locale]404 page not translatedPlace not-found.tsx inside [locale]
locale missing from memo depsStale locale after language switchAdd locale to useMemo/useCallback deps

next-intl is a great library once configured correctly. The most impactful prevention is restricting Next.js's native Link, useRouter, and redirect via ESLint — catching the import mistake at write time rather than at runtime.

Related articles: