Better I18NBetter I18N
Next.js

Middleware

Next.js middleware configuration with Clerk-style callback pattern

Better i18n provides a powerful middleware architecture for Next.js that handles locale detection, routing, and header management. Our middleware uses a Clerk-style callback pattern that makes it easy to integrate with authentication and other middleware logic while preserving all i18n headers.

Define your i18n config once, use it everywhere:

i18n/config.ts
import { createI18n } from "@better-i18n/next";

export const i18n = createI18n({
  project: "your-org/your-project",
  defaultLocale: "en",
  localePrefix: "always",
});
middleware.ts
import { i18n } from "./i18n/config";

// Simple usage
export default i18n.betterMiddleware(); 

// Or with auth callback (Clerk-style)
export default i18n.betterMiddleware(async (request, { locale }) => { 
  // Auth logic here - locale is available!
});

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
i18n/request.ts
import { i18n } from "./config";

// Just re-export - config is already set!
export default i18n.requestConfig;

Using createI18n lets you define your project config once and reuse it across middleware, request config, and data fetching.

Standalone Setup

If you prefer standalone middleware without the unified config:

middleware.ts
import { createBetterI18nMiddleware } from "@better-i18n/next";

export default createBetterI18nMiddleware({
  project: "your-org/your-project",
  defaultLocale: "en",
  localePrefix: "always", // "always" | "as-needed" | "never"
});

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Configuration Options

OptionTypeDefaultDescription
projectstringRequiredYour project identifier (e.g., acme/web).
defaultLocalestringRequiredFallback locale code (e.g., en).
localePrefixstring"as-needed"URL prefix behavior: "always", "as-needed", or "never".
detection.browserLanguagebooleantrueAuto-detect language from browser headers.
detection.cookiebooleantrueUse cookie for language persistence.
detection.cookieNamestring"locale"Name of the cookie to store preference.
detection.cookieMaxAgenumber31536000Cookie max age in seconds (default: 1 year).

Auth Integration (Clerk-style Pattern)

The recommended way to combine i18n with authentication is using the callback pattern. This ensures all i18n headers are preserved while giving you full access to the detected locale.

middleware.ts
import { createBetterI18nMiddleware } from "@better-i18n/next";
import { NextResponse } from "next/server";

export default createBetterI18nMiddleware({
  project: "acme/dashboard",
  defaultLocale: "en",
  localePrefix: "always",
}, async (request, { locale, response }) => { 
  // Auth logic runs AFTER i18n detection
  // You have access to: locale, response (with headers already set)

  const isLoggedIn = !!request.cookies.get("session")?.value;
  const isProtectedRoute = request.nextUrl.pathname.includes("/dashboard");

  if (!isLoggedIn && isProtectedRoute) {
    // Redirect to login with locale prefix
    return NextResponse.redirect(new URL(`/${locale}/login`, request.url)); 
  }

  // Return nothing = i18n response is used (all headers preserved!)
});

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Callback Context

The callback receives two arguments:

ArgumentTypeDescription
requestNextRequestThe incoming Next.js request
context.localestringThe detected locale (e.g., "en", "tr")
context.responseNextResponseThe i18n response with headers already set

Return Values

ReturnBehavior
NextResponseShort-circuits and uses your response (e.g., redirect)
void / undefinedContinues with the i18n response (headers preserved)

Advanced: NextAuth.js Integration

middleware.ts
import { createBetterI18nMiddleware } from "@better-i18n/next";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";

const protectedRoutes = ["/dashboard", "/settings", "/profile"];

export default createBetterI18nMiddleware({
  project: "acme/app",
  defaultLocale: "en",
  localePrefix: "always",
}, async (request, { locale }) => {
  const token = await getToken({ req: request });
  const isProtected = protectedRoutes.some(route =>
    request.nextUrl.pathname.includes(route)
  );

  if (!token && isProtected) {
    const loginUrl = new URL(`/${locale}/auth/signin`, request.url);
    loginUrl.searchParams.set("callbackUrl", request.url);
    return NextResponse.redirect(loginUrl);
  }
});

Advanced: Better Auth Integration

middleware.ts
import { createBetterI18nMiddleware } from "@better-i18n/next";
import { NextResponse } from "next/server";

const publicRoutes = ["/", "/login", "/register", "/forgot-password"];

export default createBetterI18nMiddleware({
  project: "acme/app",
  defaultLocale: "en",
  localePrefix: "always",
}, async (request, { locale }) => {
  const sessionCookie = request.cookies.get("better-auth.session_token")?.value;
  const pathname = request.nextUrl.pathname;

  // Remove locale prefix to check route
  const pathWithoutLocale = pathname.replace(/^\/(en|tr|de)/, "") || "/";
  const isPublicRoute = publicRoutes.includes(pathWithoutLocale);

  if (!sessionCookie && !isPublicRoute) {
    return NextResponse.redirect(new URL(`/${locale}/login`, request.url));
  }

  // Redirect logged-in users away from auth pages
  if (sessionCookie && ["/login", "/register"].includes(pathWithoutLocale)) {
    return NextResponse.redirect(new URL(`/${locale}/dashboard`, request.url));
  }
});

Locale Prefix Modes

The localePrefix option controls how locales appear in your URLs. Choose the mode that best fits your routing strategy.

"always" — Every URL Has a Locale Prefix

Every page includes the locale in the URL, including the default locale.

/en/about    → English
/tr/about    → Turkish
/de/about    → German

Best for SEO-focused sites that want explicit locale signals in every URL.

"as-needed" (default) — Only Non-Default Locales Get a Prefix

The default locale has no prefix. Other locales get a prefix.

/about       → English (default)
/tr/about    → Turkish
/de/about    → German

Recommended for most sites — clean URLs for the primary language, explicit prefixes for others.

"never" — No Locale in URLs

All locales share the same URL. The active locale is determined by cookie and Accept-Language header.

/about       → English, Turkish, German (same URL for all)

Ideal for apps where locale is a user preference rather than a URL-level concern (e.g., dashboards, SaaS products).

localePrefix: "never" requires understanding how the middleware rewrites work under the hood. Read the sections below carefully before using this mode.

Required File Structure

Even with "never" mode, you still need the [locale] dynamic segment in your app/ directory:

app/
  [locale]/
    layout.tsx     ← still required!
    page.tsx
    about/
      page.tsx

The middleware performs an invisible rewrite — it detects the locale from cookie/headers and rewrites the request internally to /{locale}/.... The [locale] segment never appears in the browser URL, but Next.js file-based routing still relies on it.

Layout Validation — Use Dynamic Locales

Instead of hardcoding locales in generateStaticParams, fetch them dynamically from the CDN:

app/[locale]/layout.tsx
import { i18n } from "@/i18n/config";

export async function generateStaticParams() {
  const locales = await i18n.getLocales(); 
  return locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
  // ...
}

Using i18n.getLocales() means adding a new language in the dashboard requires zero code changes — the next build picks it up automatically.

Routing and Navigation

In "never" mode, routing.locales has no effect on URL generation — usePathname, Link, and other navigation APIs work as pass-through since there is no locale segment to manage.

You can keep your routing.ts file for other configuration, but the locale list won't affect URL behavior. Alternatively, you can import directly from next/navigation — both approaches work identically in "never" mode.

SettingBetter i18nnext-intl
Default cookie name"locale""NEXT_LOCALE"

If you're migrating from next-intl, set cookieName: "NEXT_LOCALE" to preserve existing user preferences:

i18n/config.ts
createI18n({
  project: "acme/app",
  defaultLocale: "en",
  localePrefix: "never",
  cookieName: "NEXT_LOCALE", // Preserve next-intl cookies during migration
});

The useSetLocale hook updates this cookie automatically. With BetterI18nProvider, it triggers an instant CDN fetch + client re-render. Without the provider, it sets the cookie and calls router.refresh().

Accidental Locale Prefix Redirect

If a user visits /tr/about while localePrefix is set to "never", the middleware automatically redirects to /about and sets the locale cookie to tr. This prevents duplicate content and ensures URLs stay clean.

The middleware also guards against double-prefix bugs (e.g., /tr/tr/about) — any stacked prefixes are stripped and redirected.


How It Works

  1. Detection Priority:

    • Path parameter (/tr/abouttr)
    • Cookie (locale → value)
    • Accept-Language header (Browser preference)
    • Fallback to defaultLocale
  2. Headers Set:

    • x-middleware-request-x-next-intl-locale - For next-intl compatibility
    • x-locale - For Server Components access
  3. Cookie Persistence: If no cookie is present, the middleware sets one to persist the detected language.

Accessing Locale in Server Components

app/[locale]/page.tsx
import { headers } from "next/headers";

export default async function Page() {
  const headersList = await headers();
  const locale = headersList.get("x-locale") ?? "en";
  // Use locale for server-side logic
}

Advantages over Static Middleware

Better i18n's middleware architecture provides several advantages over traditional static middleware setups (like next-intl's createMiddleware):

FeaturebetterMiddlewareStatic middleware
Locale listDynamic — add a language in the dashboard, no deploy neededHardcoded array — requires code changes and redeployment
Middleware bundle sizeMinimal — translations load from CDN at runtimeMessages can be bundled — risks hitting Next.js 15's 1MB edge function limit
Detection granularitycookie and browserLanguage toggle independentlySingle localeDetection boolean for all detection
Auth compositionClerk-style callback — i18n headers always preservedManual middleware chaining — risk of losing headers between steps

Dynamic locale lists mean your middleware never goes stale when you add or remove languages. The CDN manifest is the single source of truth.


Migration from composeMiddleware

composeMiddleware is deprecated. Please migrate to the callback pattern.

The callback pattern is more reliable because it guarantees header preservation:

Before (deprecated)
import { createBetterI18nMiddleware, composeMiddleware } from "@better-i18n/next"; 

const i18n = createBetterI18nMiddleware({ ... }); 
const auth = async (req) => { /* auth logic */ }; 

// Headers could be lost!
export default composeMiddleware(i18n, auth); 
After (recommended)
import { createBetterI18nMiddleware } from "@better-i18n/next"; 

export default createBetterI18nMiddleware({ 
  project: "acme/app", 
  defaultLocale: "en", 
}, async (request, { locale, response }) => { 
  // Auth logic here - headers are ALWAYS preserved!
  if (needsRedirect) {
    return NextResponse.redirect(new URL(`/${locale}/login`, request.url));
  }
}); 

Why the Callback Pattern is Better

FeaturecomposeMiddlewareCallback Pattern
Header preservation⚠️ Can be lost✅ Always preserved
Locale access❌ Manual parsingcontext.locale
Response modification❌ Complexcontext.response
API simplicity❌ Multiple functions✅ Single function

Legacy: i18n.middleware

If you are using the legacy i18n.middleware property:

i18n.ts
import { createI18n } from "@better-i18n/next";

export const i18n = createI18n({
  project: "my-project",
  defaultLocale: "en",
});
middleware.ts
import { i18n } from "./i18n";
export const middleware = i18n.middleware;

The i18n.middleware property is deprecated — use i18n.betterMiddleware() instead.

On this page