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
// 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
// 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.
Pitfall 2: Mixing next-intl's Link with Next.js's Link
Using Next.js's Link directly breaks locale prefix behavior during navigation.
❌ Problem: Using Next.js Link directly
// ❌ 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.
✅ Fix: Use next-intl's Link
// ✅ 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:
// ❌ 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:
// ❌ 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>;
}
// ✅ 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 Component | Client Component | |
|---|---|---|
| Translations | getTranslations (await required) | useTranslations |
| Current locale | getLocale | useLocale |
| All messages | getMessages | useMessages |
Pitfall 4: Missing Locale in generateStaticParams
In multilingual sites, generateStaticParams must include locale as a parameter.
❌ Generating without locale (English articles return 404)
// 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
// ✅ 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.
// ❌ 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!
},
},
};
}
// ✅ 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
// ❌ 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>
);
}
// ✅ 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.
// ❌ 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>;
}
// ✅ 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:
| Pitfall | Symptom | Fix |
|---|---|---|
| Incomplete matcher | Root redirect doesn't work | Use the correct three-part matcher pattern |
| Next.js Link instead of next-intl Link | Locale prefix disappears on navigation | Use Link from @/i18n/routing |
useTranslations in Server Component | Runtime error | Use getTranslations (with await) |
No locale in generateStaticParams | Non-default locale returns 404 | Generate locale × slug combinations |
Missing x-default | Incomplete hreflang SEO | Add x-default to alternates.languages |
not-found.tsx outside [locale] | 404 page not translated | Place not-found.tsx inside [locale] |
locale missing from memo deps | Stale locale after language switch | Add 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.