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
// 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|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.
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. See the next-intl navigation docs for the full API.
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 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.
// ⚠️ 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>;
}
// ✅ 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 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 (non-default locale 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", "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.
// ❌ 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!
},
},
};
}
// ✅ 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
// ❌ 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. 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.
// ❌ 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. 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.
How do I set up an ESLint rule to prevent importing from next/link directly?
Use the built-in no-restricted-imports rule in your ESLint config:
{
"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:
| 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 async Server Component | Page forced into dynamic rendering | 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.
Related articles: