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>
);
}