Tutorials

Setting Up i18n in Next.js App Router — The Complete 2026 Guide

Eray Gündoğmuş
Eray Gündoğmuş
·25 min read
Share
Setting Up i18n in Next.js App Router — The Complete 2026 Guide

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 useTranslations in them — you need getTranslations from the server.
  • Middleware now handles locale detection and routing instead of next.config.js i18n configuration (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 hreflang tags 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:

  1. Automatic locale detection from URL, cookies, and browser headers
  2. Server-side translation loading with ISR caching
  3. Client-side instant locale switching without page reload
  4. SEO-optimized routing with hreflang and canonical URLs
  5. 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:

PropertyWhat It Does
i18n.configNormalized configuration with defaults applied
i18n.requestConfignext-intl request config for App Router
i18n.middlewareLegacy 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:

  1. URL path/fr/about resolves to fr
  2. Cookie — The locale cookie (set on previous visits)
  3. Browser header — The Accept-Language header
  4. 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:

  1. Resolves the locale from the middleware header (or falls back to cookie, then default)
  2. Fetches translations from the CDN with Next.js ISR revalidation
  3. Resolves the time zone to prevent hydration mismatches
  4. 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.

LocaleURL
en (default)/about
fr/fr/about
tr/tr/about
createI18n({ localePrefix: "as-needed", defaultLocale: "en" });

"always"

Every locale gets a prefix, including the default.

LocaleURL
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.

LocaleURL
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:

  1. Sets a locale cookie for server-side persistence on the next navigation
  2. Fetches the new translations from CDN on the client
  3. 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:

  1. Memory cache — In-process TTL cache (fastest)
  2. CDN fetch — With configurable timeout and retry
  3. Persistent storage — For offline/degraded scenarios
  4. 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

  1. You write your React component with English strings
  2. Ask your AI assistant: "Add French and Turkish translations for the home page"
  3. The MCP server creates the keys, proposes translations, and publishes them
  4. 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>

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.