Next-intl (i18n) language reset to previous locale in Vercel

Middleware Issue Review (VERCEL)

Repo

Overview

  • After deploying to Vercel, an issue occurs where the language setting resets when I change the language and navigates to a different link.

Background

Environment: next.js 15 with Turbo, next-intl for multilingual support, Vercel deployment

  "next": "15.1.7",
  "react": "^19.0.0",
  "next-intl": "^3.26.3",

Problem Situation: When a user changes the language (e.g., from ko to en) and navigates to another link, the language reverts to the default (e.g., ko or en).

  • In a similar e-commerce project, no issues occurred when using the component from next/link in the header.

  • Works correctly locally, but the issue only occurs in the Vercel deployment environment.

Related Code

  • middleware: Integration of next-intl and next-auth, handling language and authentication logic.

  • i18n/routing.ts: Routing configuration for next-intl (localePrefix: “as-needed”).

Cause Analysis

Client-Server State Inconsistency:

Developer Tools → Inspect → Network Logs

  • /ko/about → 307 Temporary Redirect → /about

  • /ko/about → /about, next-Url: /vn/about → Confirmed that the client maintains the previous language (vn).

    • Location: Client router (next-Url - observable in the network request headers in developer tools).
  • /about → next-Url: /vn/about persists → Next request goes to /vn.

Suspected Cause

  • Inconsistency between the client router state (next-Url) and the server middleware.

Actions for Problem Resolution

1. What I have tried

// i18n/routing.ts 

localeCookie: { name: "NEXT_LOCALE" }, // <--- did not solve
localeDetection: false,  // <--- did not solve

localePrefix: { mode: "as-needed" },  // <--- did not solve

localePrefix: { mode: "as-needed", { 
		prefixes: {
				"ko": "/",
				"en": "/en", 
				"vn": "/vn",  
} },  // <--- did not solve

localePrefix: { mode: "always", { 
		prefixes: {
				"ko": "/ko",
				"en": "/en", 
				"vn": "/vn",  
} },  // <--- did not solve

localePrefix: "always",  // <--- did not solve

pathnames: { ... },  // <--- did not solve

i18n/routing.ts file code

// i18n/routing.ts

import { routesData } from "@/lib/db/data/routes-data";
import { createNavigation } from "next-intl/navigation";
import { defineRouting } from "next-intl/routing";

// routesData 에서 모든 경로 추출
const allPaths = [
  ...routesData.public.static,
  ...routesData.public.dynamic,
  ...routesData.protected.static,
  ...routesData.protected.dynamic,
];

const pathnames = Object.fromEntries(allPaths.map((path) => [path, path]));

/* ROUTING CONFIGURATION
 * =============================
 * [Step D] Routing 설정을 정의한다.
 *   - locales: 언어 목록
 *   - defaultLocale: 기본 언어
 *   - localeCookie: 쿠키 설정 (선택)
 *   - localePrefix: 언어별 prefix 설정 - 모드 설정 시 pathnames와 관계 없이 prefix 설정
 *   - pathnames: URL 경로가 다른 경우 필요
 * ============================= */
export const routing = defineRouting({
  locales: ["ko", "en", "vn"],
  defaultLocale: "ko",
  // localeDetection: false,
  // localeCookie: { name: "NEXT_LOCALE" },
  localePrefix: "always",
  pathnames,
});

export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);

Here’s the middleware code.

middleware.ts

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import NextAuth from "next-auth";
import authConfig from "./lib/auth.config";
import { protectedPaths, publicPaths } from "./lib/db/data/routes-data";
import { NextResponse } from "next/server";
import { isMatch } from "./lib/utils";


/* 다국어 미들웨어 생성(routing 객체 전달, createMiddleware 함수 사용)
 * =========================================
 * [Pre-A] next-intl / next-auth 설정
 * ========================================= */
const intlMiddleware = createMiddleware(routing);


// nextAuth 인증 미들웨어 래퍼
const { auth } = NextAuth(authConfig);

/* ####### MIDDLEWARE FUNCTION #######
 * =========================================
 * MAIN MIDDLEWARE
 * ========================================= */
export default auth((req) => {
  // [Step A] next-intl 미들웨어 (rewrite/locale 설정 등) 먼저 실행
  const { pathname } = req.nextUrl;

  const locales = routing.locales;
  const defaultLocale = routing.defaultLocale;
  /* URL PREFIX 추출
   * =============================
   * [Step A] URL에서 prefix 를 추출한다.
   *   - locales: 언어 목록
   *   - defaultLocale: 기본 언어
   *  ============================= */
  const pathnameParts = pathname.split("/").filter(Boolean); // 빈 문자열 제거
  const prefix = pathnameParts[0] || "";
  const pathnameSegments = req.nextUrl.pathname.split("/");

  console.log("[middleware - Step B] pathname comes: ", pathname);
  console.log("[middleware - Step B] prefix extracted: ", pathnameParts);
  console.log("[middleware - Step B] pathnameSegments: ", pathnameSegments);

  /* LANGUAGE SETTINGS
   * =============================
   * [Step B] 현재 URL 에서 locale을 감지한다.
   *   - urlLocale : URL에서 추출한 언어(prefix)
   *   - userLocale: 쿠키에서 추출한 언어
   *   - currentLocale: URL에서 추출한 언어(prefix) -> 쿠키 -> 기본 언어 순서
   *  ============================= */
  const urlLocale = locales.includes(prefix as "ko" | "en" | "vn") ? prefix : null;
  const userLocale = req.cookies.get("NEXT_LOCALE")?.value;
  const currentLocale = userLocale || defaultLocale;

  console.log(
    "[middleware - Step C]: ",
    "[urlLocale]: ",
    urlLocale,
    "[userLocale]: ",
    userLocale,
    "[currentLocale]: ",
    currentLocale,
  );

  /* PATH CHECK
   * =============================
   * [Step C] 공개/비공개 경로 확인
   *   - pathWithoutPrefix: prefix를 제거한 경로
   *   - isPublicRoute: 요청된 경로가 공개 경로인지 확인
   * ============================= */

  const pathWithoutPrefix = `/${pathnameParts.slice(1).join("/")}` || "/";
  const isPublicRoute = isMatch(pathWithoutPrefix, publicPaths);
  const isProtectedRoute = isMatch(pathWithoutPrefix, protectedPaths);
  console.log("[middleware - Step D] pathWithoutPrefix: ", pathWithoutPrefix);
  console.log("[middleware - Step D] isPublicRoute: ", isPublicRoute);
  console.log("[middleware - Step D] isProtectedRoute: ", isProtectedRoute);

  // 모든 경로에 대해 intlMiddleware 실행(잘못된 경로는 외부처리)
  let response = NextResponse.next(); // 기본 응답 생성

  /* COOKIE SETTINGS
   * =============================
   * [Step D] NEXT_LOCALE 쿠키 설정
   *   - 조건에 따라 쿠키를 설정한다.
   *   - a). NEXT_LOCALE 쿠키를 가져오지 못한 경우
   *   - b). NEXT_LOCALE 쿠키가 있지만, 현재 언어(currentLocale)와 다른 경우
   *  ============================= */
  if (!req.cookies.get("NEXT_LOCALE") || req.cookies.get("NEXT_LOCALE")?.value !== currentLocale) {
    response.cookies.set("NEXT_LOCALE", currentLocale, {
      path: "/",
      maxAge: 60 * 60 * 24 * 30, // 30 days
      secure: process.env.NODE_ENV === "production", // VERCEL HTTPS support
    });
    console.log("[middleware - Step D] NEXT_LOCALE cookie set early: ", currentLocale);
  }

  // 공개 경로 처리
  if (isPublicRoute) {
    return intlMiddleware(req);
  }

  // 비공개 경로 처리
  if (isProtectedRoute) {
    if (!req.auth) {
      const loginUrl = new URL(`/login?callbackUrl=${encodeURIComponent(pathname)}`, req.nextUrl.origin);

      return NextResponse.redirect(loginUrl);
    }
  }

  // console.log("[middleware - Step D] NEXT_LOCALE cookie set: ", currentLocale);

  // console.log("[middleware - Final] Response locale: ", currentLocale);
  response = intlMiddleware(req);

  return response;
});

/* =========================================
 * next.js 미들웨어 설정
 * ========================================= */
export const config = {
  // matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
  // matcher: ["/", "/(ko|en|vn)/:path*"],
  // matcher: ["/((?!api|_next|.*\\..*).*)"],
  matcher: ["/((?!api|image|_next|favicon.ico|_next/image|icons|_next/static|_vercel\\..*).*)"],
};

Language-switcher.tsx file code

// components/shared/header/language-switcher.tsx

"use client";

import * as React from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { i18n } from "@/i18n-config";
import { Link, usePathname } from "@/i18n/routing";
import { useLocale } from "next-intl";
import Image from "next/image";

/**
 * 브라우저에서 NEXT_LOCALE 쿠키를 세팅하는 함수
 * - maxAge: 1년 (필요하면 조정)
 * - Secure: HTTPS 환경에서만 전송 (Vercel이라면 보통 자동 HTTPS)
 * - SameSite=Lax
 */

export function cleanPathname(pathname: string, locale: string) {
  const localePrefix = `/${locale}`;

  return pathname.startsWith(localePrefix) ? pathname.replace(localePrefix, "") || "/" : pathname;
}

export default function LanguageSwitcher() {
  const { locales } = i18n;
  const locale = useLocale();
  const pathname = usePathname();
  // console.log("[DEBUG] LanguageSwitcher - locale: ", locale);

  function setLocaleCookie(locale: string) {
    const maxAge = 60 * 60 * 24 * 365; // 1년
    document.cookie = `NEXT_LOCALE=${locale}; Path=/; Max-Age=${maxAge}; Secure; SameSite=Lax`;
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger className="header-button h-16">
        <div className="flex items-center gap-1 text-base">
          <Image
            src={locales.find((l) => l.slug === locale)?.icon || locales[0].icon}
            alt={"language"}
            width={28}
            height={28}
          />
          {locale.toUpperCase().slice(0, 2)}
        </div>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-40" align="center">
        <DropdownMenuLabel>Language</DropdownMenuLabel>
        <DropdownMenuRadioGroup value={locale}>
          {locales.map((c) => (
            // 현재 언어는 선택 불가능하게
            <DropdownMenuRadioItem key={c.name} value={c.slug} disabled={c.slug === locale}>
              {/* <div className="w-full flex items-center gap-2 text-sm">
               <Image src={c.icon} alt={c.name} width={28} height={28} />
               {c.name}
             </div> */}
              <Link
                href={pathname}
                locale={c.slug}
                className="w-full flex items-center gap-2 text-sm"
                onClick={() => {
                  setLocaleCookie(c.slug);
                }}>
                <Image src={c.icon} alt={c.name} width={28} height={28} />
                {c.name}
              </Link>
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Header

// components/shared/header/index.tsx

import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { ThemeToggleButton } from "./theme-toggle-button";
import { SideSheet } from "./side-sheet";
import data from "@/lib/db/data/data";
import LanguageSwitcher from "./language-switcher";
import { useLocale } from "next-intl";

export default function Header() {
  const locale = useLocale();
  console.log("[DEBUG] Header - locale: ", locale);
  return (
    <header className="h-16 bg-background/40 sticky top-0 border-b px-8 backdrop-blur-sm flex items-center justify-between">
      {/* HEADER - 1: LOGO  */}
      <div className="flex items-center">
        <Link href={"/"}>
          <h1 className="font-bold text-lg md:text-xl text-nowrap">CCB Alliance</h1>
        </Link>
      </div>

      {/* HEADER -2: MENUS */}
      <div className="hidden md:flex w-full justify-end space-x-4 items-center">
        {data.headerMenus.map((menu) => (
          <Link key={menu.href} href={`${menu.href}`} className="text-base p-2">
            {menu.name}
          </Link>
        ))}
      </div>

      <div className="hidden md:flex justify-end w-40 px-2">
        {/* <LanguageSwitcher key={locale} /> */}
        <LanguageSwitcher />
      </div>
      {/* HEADER -3: LOGIN */}
      <div className="hidden md:flex items-center gap-2 px-2">
        <Link href={"/login"} className={buttonVariants({ variant: "outline" })}>
          Login
        </Link>
        <Link href={"/register"} className={buttonVariants({ variant: "outline" })}>
          Sign Up
        </Link>
      </div>
      <div className="w-fit flex gap-4 items-center">
        <div className="md:hidden flex justify-end w-40 ">
          <LanguageSwitcher />
        </div>
        <ThemeToggleButton />
        <SideSheet />
      </div>
    </header>
  );
}

Notes

I defined the routePaths files.

// lib/db/data/routes-data.ts

export const routesData = {
  // 공개 경로
  public: {
    static: ["/", "/blog", "/search", "/login", "/register", "/about", "/contact"],
    dynamic: ["/blogpost/[slug]"],
  },
  protected: {
    static: ["/dashboard", "/profile", "/admin"],
    dynamic: ["/dashboard/settings", "/profile/[id]", "/admin/[id]"],
  },
};

export const publicPaths = new Set([...routesData.public.static, ...routesData.public.dynamic]);
export const protectedPaths = new Set([...routesData.protected.static, ...routesData.protected.dynamic]);

Issue

  1. The problem is that when I use those routesData from i18n/routing.ts file, then if I tried to access to a locale then try to change to another then I got an 404 not found message.
  2. when I did not use pathnames, no error happened.
// i18n/routing.ts

import { routesData } from "@/lib/db/data/routes-data";
import { createNavigation } from "next-intl/navigation";
import { defineRouting } from "next-intl/routing";

// routesData 에서 모든 경로 추출
// const allPaths = [
//   ...routesData.public.static,
//   ...routesData.public.dynamic,
//   ...routesData.protected.static,
//   ...routesData.protected.dynamic,
// ];
//
// const pathnames = Object.fromEntries(allPaths.map((path) => [path, path]));

/* ROUTING CONFIGURATION
 * =============================
 * [Step D] Routing 설정을 정의한다.
 *   - locales: 언어 목록
 *   - defaultLocale: 기본 언어
 *   - localeCookie: 쿠키 설정 (선택)
 *   - localePrefix: 언어별 prefix 설정 - 모드 설정 시 pathnames와 관계 없이 prefix 설정
 *   - pathnames: URL 경로가 다른 경우 필요
 * ============================= */
export const routing = defineRouting({
  locales: ["ko", "en", "vn"],
  defaultLocale: "ko",
  // localeDetection: false,
  // localeCookie: { name: "NEXT_LOCALE" },
  localePrefix: "always",
  // pathnames,
});

export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);