Middleware Issue Review (VERCEL)
Repo
https://github.com/miniu-jacob/next-ccunion.git
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 solvelocaleDetection: 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 solvei18n/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.tsimport 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> );}