32blogby StudioMitsu
nextjs8 min read

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.

nextjsnext-intli18ninternationalizationApp Routerpitfalls
On this page

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

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)/: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.

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.

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 a Server Component

next-intl uses different functions for Server and Client Components:

tsx
// ❌ useTranslations doesn't work in Server Components
// No "use client" = Server Component
import { useTranslations } from "next-intl";

export default async function HomePage() {
  // Error: useTranslations is not supported in Server Components
  const t = useTranslations("home");

  return <h1>{t("title")}</h1>;
}
tsx
// ✅ Server Components use getTranslations (with await)
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:

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 (English 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"] 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 articles are returning 404 after adding them, this is almost always the cause.

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}`,
        // 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}`,
        // 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.

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.

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.

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 Server ComponentRuntime errorUse 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.