Table of Contents
Table of Contents
- Why i18n in Next.js App Router Is Different
- Why i18n Matters
- What You Will Build
- 1. Installation
- 2. Create Your i18n Configuration
- Configuration Options
- 3. Configure the Middleware
- Simple Setup
- With Authentication (Clerk-Style Callback)
- How Locale Detection Works
- 4. Set Up the Request Config
- 5. Using Translations in Components
- Server Components
- Client Components
- Namespace Scoping
- 6. URL Locale Prefix Strategies
- "as-needed" (Default)
- "always"
- "never"
- 7. Client-Side Locale Switching
- Instant Switching with BetterI18nProvider
- Building a Dynamic Language Picker
- 8. SEO: hreflang, Canonical URLs, and Metadata
- Generate hreflang Tags
- Localized Metadata
- 9. Offline Fallback and Resilience
- 10. AI-Powered Translation with MCP
- Example Workflow
- Putting It All Together
- Quick Reference
- Common Pitfalls (and How to Avoid Them)
- 1. Hydration Mismatches with Date/Time Formatting
- 2. Missing Locale Prefix on Default Locale
- 3. Cookie Not Persisting Across Subdomains
- 4. Middleware Matcher Not Excluding Static Files
- Conclusion
- Related Resources
If you are building a Next.js application that needs to support multiple languages, you have likely run into a mess of configuration files, broken middleware chains, and translations that refuse to load in server components. This guide walks you through setting up internationalization (i18n) in Next.js 15 App Router using @better-i18n/next — from zero to production-ready in under 30 minutes.
Why i18n in Next.js App Router Is Different
If you used i18n with the Pages Router, forget most of what you know. App Router changed the game:
- Server Components are the default. You cannot use React hooks like
useTranslationsin them — you needgetTranslationsfrom the server. - Middleware now handles locale detection and routing instead of
next.config.jsi18nconfiguration (which was removed in Next.js 13+). - ISR (Incremental Static Regeneration) lets you cache translations at the edge and revalidate in the background — no full rebuilds when translations change.
- Streaming means your layout can render immediately while translations load asynchronously.
Most i18n libraries struggled to adapt. @better-i18n/next was built specifically for this architecture, delivering translations from a global CDN with ISR caching and a composable middleware API.
Why i18n Matters
Over 60% of internet users prefer browsing in their native language. For SaaS products, e-commerce stores, and content platforms built with Next.js, localization is not a nice-to-have — it is a growth multiplier. Properly implemented i18n improves:
- SEO rankings in non-English markets through
hreflangtags and localized URLs. A solid localization SEO strategy compounds the value of every translated page. - Conversion rates by 70%+ when users see content in their language
- User retention through a native-feeling experience
The shift toward developer-first tooling has also made i18n faster to adopt. Why developer-first localization wins in 2026 explains the broader forces driving teams away from translator-centric workflows and toward code-native setups like the one you are building here.
If you are coming from a mobile background, note that the patterns here differ from React Native Expo localization — App Router uses server-side message loading and ISR caching rather than bundled locale files, which is a meaningful architectural difference. For a broader introduction to the vocabulary of internationalization before diving into the code, localization and internationalisation fundamentals is a useful primer.
What You Will Build
By the end of this guide, your Next.js app will have:
- Automatic locale detection from URL, cookies, and browser headers
- Server-side translation loading with ISR caching
- Client-side instant locale switching without page reload
- SEO-optimized routing with
hreflangand canonical URLs - Type-safe translations with namespace scoping
1. Installation
Start by installing @better-i18n/next and its peer dependencies:
npm install @better-i18n/next next-intl
@better-i18n/next requires Next.js 15+ and next-intl 4+ as peer dependencies. It builds on top of next-intl's proven request handling while adding CDN-powered translation delivery, automatic locale detection, and a composable middleware API.
2. Create Your i18n Configuration
Create a central configuration file that the rest of your app will reference. This is the single source of truth for your i18n setup.
// i18n/config.ts
import { createI18n } from "@better-i18n/next";
export const i18n = createI18n({
project: "acme/dashboard", // Your Better i18n project identifier
defaultLocale: "en", // Fallback locale
localePrefix: "as-needed", // URL strategy (see Section 6)
timeZone: "UTC", // Consistent date/time formatting
});
The createI18n function returns an object with everything you need:
| Property | What It Does |
|---|---|
i18n.config | Normalized configuration with defaults applied |
i18n.requestConfig | next-intl request config for App Router |
i18n.middleware | Legacy middleware (next-intl based) |
i18n.betterMiddleware() | Modern composable middleware with auth callback |
i18n.getLocales() | Fetch available locales from CDN |
i18n.getMessages() | Fetch translations for a locale with ISR caching |
Configuration Options
The I18nConfig interface extends the core config with Next.js-specific options:
interface I18nConfig {
project: string; // "org/project" format
defaultLocale: string; // e.g., "en"
localePrefix?: "as-needed" | "always" | "never";
cookieName?: string; // Default: "locale"
manifestRevalidateSeconds?: number; // ISR for manifest (default: 3600)
messagesRevalidateSeconds?: number; // ISR for translations (default: 30)
timeZone?: string; // IANA time zone identifier
storage?: TranslationStorage; // Offline fallback storage
staticData?: Record<string, Messages>; // Bundled fallback translations
fetchTimeout?: number; // CDN timeout in ms (default: 10000)
retryCount?: number; // Retry attempts (default: 1)
}
3. Configure the Middleware
The middleware handles locale detection and URL routing. It runs on every request, detects the user's preferred language, and ensures the URL structure matches your locale prefix strategy.
Simple Setup
For most apps, a one-liner is all you need:
// middleware.ts
import { i18n } from "./i18n/config";
export default i18n.betterMiddleware();
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)`"],
};
With Authentication (Clerk-Style Callback)
If you need to combine i18n with authentication, betterMiddleware accepts a callback that gives you access to the detected locale and the i18n response:
// middleware.ts
import { NextResponse } from "next/server";
import { i18n } from "./i18n/config";
export default i18n.betterMiddleware(async (request, { locale, response }) => {
const isProtected = request.nextUrl.pathname.includes("/dashboard");
const isLoggedIn = request.cookies.get("session")?.value;
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(
new URL(`/${locale}/login`, request.url)
);
}
// Return nothing = the i18n response is used (headers preserved!)
});
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)`"],
};
This pattern replaces the deprecated composeMiddleware approach. The callback receives the fully resolved locale and the response with all i18n headers already set, so you can focus on your auth logic without worrying about header conflicts.
How Locale Detection Works
The middleware detects the user's locale using a priority chain:
- URL path —
/fr/aboutresolves tofr - Cookie — The
localecookie (set on previous visits) - Browser header — The
Accept-Languageheader - Default — Falls back to
defaultLocale
Available locales are fetched from the Better i18n CDN on each request (cached in memory). When a new locale is detected, a cookie is automatically set for future visits.
4. Set Up the Request Config
The request config tells next-intl how to load translations for each request. Better i18n handles this by fetching messages from CDN with ISR caching.
// i18n/request.ts
import { i18n } from "./config";
export default i18n.requestConfig;
Under the hood, requestConfig does the following on every server request:
- Resolves the locale from the middleware header (or falls back to cookie, then default)
- Fetches translations from the CDN with Next.js ISR revalidation
- Resolves the time zone to prevent hydration mismatches
- Returns
{ locale, messages, timeZone }to next-intl
The ISR strategy means translations are cached on the server and revalidated in the background — manifest data revalidates every 3600 seconds (1 hour) and translation messages every 30 seconds by default. You can tune this with the manifestRevalidateSeconds and messagesRevalidateSeconds config options.
5. Using Translations in Components
Server Components
In server components, use the getTranslations function from next-intl:
// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";
export default async function HomePage() {
const t = await getTranslations("home");
return (
<main>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
</main>
);
}
Client Components
In client components, use the useTranslations hook:
"use client";
import { useTranslations } from "next-intl";
export function WelcomeBanner() {
const t = useTranslations("home");
return (
<section>
<h2>{t("welcome")}</h2>
<p>{t("subtitle", { name: "Developer" })}</p>
</section>
);
}
Namespace Scoping
Translations are organized by namespace. When you call useTranslations("home") or getTranslations("home"), you are scoping to the home namespace in your translation files:
{
"home": {
"title": "Welcome to Acme",
"description": "The best dashboard for your business",
"welcome": "Hello, {name}!",
"subtitle": "Let's get started"
},
"auth": {
"login": "Sign in",
"logout": "Sign out"
}
}
This prevents key collisions across different parts of your app and keeps translation files maintainable as your app grows.
6. URL Locale Prefix Strategies
The localePrefix option controls how locales appear in URLs. Choose the strategy that fits your app:
"as-needed" (Default)
The default locale has no prefix. Other locales get a prefix.
| Locale | URL |
|---|---|
en (default) | /about |
fr | /fr/about |
tr | /tr/about |
createI18n({ localePrefix: "as-needed", defaultLocale: "en" });
"always"
Every locale gets a prefix, including the default.
| Locale | URL |
|---|---|
en | /en/about |
fr | /fr/about |
tr | /tr/about |
createI18n({ localePrefix: "always", defaultLocale: "en" });
"never"
No locale appears in the URL. Locale is determined entirely by cookie and browser headers.
| Locale | URL |
|---|---|
| Any | /about |
createI18n({ localePrefix: "never", defaultLocale: "en" });
When using "never", the middleware bypasses next-intl's URL rewriting entirely and sets the locale via the x-middleware-request-x-next-intl-locale header. The request config falls back to reading the locale cookie when the middleware header is not available.
7. Client-Side Locale Switching
Better i18n provides two approaches for switching locales on the client, depending on whether you want instant switching or a server-refreshed approach.
Instant Switching with BetterI18nProvider
Wrap your layout with BetterI18nProvider to enable instant locale switching without a page refresh:
// app/[locale]/layout.tsx
import { getLocale, getMessages } from "next-intl/server";
import { BetterI18nProvider } from "@better-i18n/next/client";
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<BetterI18nProvider
locale={locale}
messages={messages}
config={{ project: "acme/dashboard", defaultLocale: "en" }}
>
{children}
</BetterI18nProvider>
</body>
</html>
);
}
Then use the useSetLocale hook anywhere in your component tree:
"use client";
import { useSetLocale } from "@better-i18n/next/client";
export function LanguageSwitcher() {
const setLocale = useSetLocale();
return (
<div>
<button onClick={() => setLocale("en")}>English</button>
<button onClick={() => setLocale("fr")}>Francais</button>
<button onClick={() => setLocale("tr")}>Turkce</button>
</div>
);
}
When setLocale is called, it:
- Sets a
localecookie for server-side persistence on the next navigation - Fetches the new translations from CDN on the client
- Re-renders the entire tree with the new locale and messages — no page refresh
Building a Dynamic Language Picker
Use the useManifestLanguages hook to build a language picker that automatically reflects the languages configured in your Better i18n project:
"use client";
import { useManifestLanguages, useSetLocale } from "@better-i18n/next/client";
export function DynamicLanguagePicker() {
const { languages, isLoading, error } = useManifestLanguages({
project: "acme/dashboard",
defaultLocale: "en",
});
const setLocale = useSetLocale();
if (isLoading) return <div>Loading languages...</div>;
if (error) return <div>Failed to load languages</div>;
return (
<select onChange={(e) => setLocale(e.target.value)}>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName || lang.name || lang.code}
</option>
))}
</select>
);
}
The languages list is fetched from the CDN manifest with built-in request deduplication — multiple components calling useManifestLanguages will share a single network request.
8. SEO: hreflang, Canonical URLs, and Metadata
Proper SEO setup is critical for multilingual Next.js apps. Here is how to set up hreflang tags and canonical URLs. For a comprehensive breakdown of how this fits into a broader multilingual SEO strategy, see our localization SEO strategy guide.
When planning the overall information architecture of your multilingual app, a multilingual website design guide covers locale-aware navigation patterns, text expansion across languages, and RTL script considerations that feed directly into the structure you build here.
Generate hreflang Tags
Add alternate language links in your root layout or page metadata:
// app/[locale]/layout.tsx
import { i18n } from "@/i18n/config";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const { locale } = await params;
const locales = await i18n.getLocales();
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `https://yourdomain.com/${loc}`;
}
// Add x-default for search engines
languages["x-default"] = "https://yourdomain.com/en";
return {
alternates: {
canonical: `https://yourdomain.com/${locale}`,
languages,
},
};
}
This generates the following in your HTML:
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en" />
<link rel="alternate" hreflang="fr" href="https://yourdomain.com/fr" />
<link rel="alternate" hreflang="tr" href="https://yourdomain.com/tr" />
<link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en" />
<link rel="canonical" href="https://yourdomain.com/en" />
Localized Metadata
Serve translated page titles and descriptions for each locale:
// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations("meta");
return {
title: t("home.title"),
description: t("home.description"),
openGraph: {
title: t("home.title"),
description: t("home.description"),
},
};
}
9. Offline Fallback and Resilience
Production apps need to handle CDN outages gracefully. @better-i18n/next provides a three-tier fallback chain for both manifest and translation data:
- Memory cache — In-process TTL cache (fastest)
- CDN fetch — With configurable timeout and retry
- Persistent storage — For offline/degraded scenarios
- Static data — Bundled translations as last resort
// i18n/config.ts
import { createI18n } from "@better-i18n/next";
export const i18n = createI18n({
project: "acme/dashboard",
defaultLocale: "en",
fetchTimeout: 5000, // Abort CDN fetch after 5s
retryCount: 2, // Retry twice on failure
staticData: { // Bundled fallback
en: {
common: { error: "Something went wrong" },
},
},
});
If the CDN is unreachable, translations are served from persistent storage (if configured) or fall back to the bundled staticData. Your app never shows broken keys to users.
10. AI-Powered Translation with MCP
Better i18n includes an MCP (Model Context Protocol) server that lets AI assistants manage your translations directly. Instead of manually writing translation files, you can use Claude, Cursor, or any MCP-compatible tool to:
- Create translation keys with
createKeys - Propose new languages with
proposeLanguages - Update existing translations with
updateKeys - Publish to CDN with
publishTranslations
Example Workflow
- You write your React component with English strings
- Ask your AI assistant: "Add French and Turkish translations for the home page"
- The MCP server creates the keys, proposes translations, and publishes them
- Your Next.js app picks up the new translations on the next ISR revalidation cycle (30 seconds by default)
No manual JSON editing. No copy-pasting between files. The entire translation workflow happens through your AI coding assistant. To see this workflow in action with our own blog content, read how we use AI to write our own blog.
Once your translations are live across languages, it is worth running a dedicated i18n testing pass — automated checks catch missing keys, broken interpolations, and pluralization edge cases before they reach users. And if your strings need nuance — UI labels that vary by tone or context — our post on why translation context matters covers how to provide that context to AI translators effectively.
Putting It All Together
Here is the complete file structure for a Next.js App Router project with Better i18n:
your-app/
i18n/
config.ts # createI18n configuration
request.ts # next-intl request config
middleware.ts # Locale detection & routing
app/
[locale]/
layout.tsx # BetterI18nProvider wrapper
page.tsx # Server component with getTranslations
components/
LanguageSwitcher.tsx # Client-side locale switching
Quick Reference
// i18n/config.ts
import { createI18n } from "@better-i18n/next";
export const i18n = createI18n({
project: "acme/dashboard",
defaultLocale: "en",
localePrefix: "as-needed",
});
// i18n/request.ts
import { i18n } from "./config";
export default i18n.requestConfig;
// middleware.ts
import { i18n } from "./i18n/config";
export default i18n.betterMiddleware();
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)`"],
};
Common Pitfalls (and How to Avoid Them)
After helping hundreds of teams set up i18n in Next.js, here are the most frequent issues we see:
1. Hydration Mismatches with Date/Time Formatting
If you format dates or times without setting a timeZone, the server and client may render different values — causing React hydration errors.
Fix: Always set timeZone in your createI18n config:
createI18n({
project: "acme/dashboard",
defaultLocale: "en",
timeZone: "UTC", // Prevents server/client mismatch
});
Better i18n sets this automatically in the request config and the BetterI18nProvider, falling back to Intl.DateTimeFormat().resolvedOptions().timeZone if you do not specify one.
2. Missing Locale Prefix on Default Locale
With localePrefix: "as-needed" (the default), your default locale has no URL prefix. This means /about serves English but /fr/about serves French. If you forget this and hardcode locale segments in links, your default locale will break.
Fix: Use next-intl's Link component or build paths dynamically:
// Do this:
<Link href="/about">{t("nav.about")}</Link>
// Not this:
<a href="/en/about">About</a>
3. Cookie Not Persisting Across Subdomains
The default locale cookie is set with path: / but no domain. If your app spans subdomains (e.g., app.yourdomain.com and www.yourdomain.com), the cookie will not be shared.
Fix: For multi-subdomain setups, use localePrefix: "always" so the locale is always in the URL, or set a custom cookie domain in your middleware callback.
4. Middleware Matcher Not Excluding Static Files
If your middleware matcher is too broad, it will run on every request — including images, fonts, and API routes. This slows down your app and can cause unexpected locale redirects.
Fix: Always use the recommended matcher pattern:
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)`"],
};
This excludes /api/*, /_next/*, and any path containing a file extension.
Conclusion
Setting up i18n in Next.js App Router does not have to be painful. With @better-i18n/next, you get:
- Zero-config locale detection from URL, cookies, and browser headers
- CDN-powered translations with ISR caching and offline fallback
- Composable middleware that plays nicely with auth (Clerk, NextAuth, etc.)
- Instant client-side locale switching without page reload
- SEO-ready with hreflang, canonical URLs, and localized metadata
- AI-powered translation through the MCP server integration
The entire setup takes five files and under 50 lines of configuration code. Your translations are served from a global CDN, cached with ISR, and managed through your AI assistant.
Ready to get started? Install @better-i18n/next and have your first locale running in minutes:
npm install @better-i18n/next next-intl
Check out the full documentation or explore the GitHub repository for more advanced patterns.
Related Resources
- Next.js i18n Landing Page — Feature overview and comparison
- i18n for Developers — Why Better i18n is built developer-first
- CLI Code Scanning — Catch hardcoded strings before they ship