params는 Promise가 됐고, metadata는 필드 단위로 병합될 뿐 깊게 병합되지 않으며, PageProps/LayoutProps는 무언가가 생성해 주기 전까지 존재하지 않고, 페인트 전에 도는 테마 스크립트는 서버에서 렌더링돼야 한다. 넷 중 셋은 조용히 깨진다. build 초록불, 타입 초록불, 그런데 HTML이 틀렸다. 아래 내용은 전부 내가 관리하는 Next.js 16.2.10 / React 19.2.7 코드베이스 5개에서 나온 것이다.
1. params는 Promise고, await하는 건 쉬운 쪽 절반이다
마이그레이션 자체는 기계적이다. params는 15부터 이미 Promise였고, 16이 한 일은 그걸 무시할 수 있게 해주던 임시 동기 접근을 없앤 것이다. launch-template/src/app/[locale]/layout.tsx의 실제 시그니처는 이렇다.
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!hasLocale(LOCALES, locale)) notFound();cookies()와 headers()도 같은 길을 갔다. launch-template/src/lib/preview.ts에서.
export async function getNavOverlay(): Promise<boolean> {
try {
const c = await cookies();
const v = c.get(NAV_OVERLAY_COOKIE)?.value;시간을 잡아먹은 건 2차 효과 쪽이었다. layout이 async가 되고 params를 await하고 나면, 그 아래에서 렌더링되는 모든 서버 컴포넌트도 여전히 locale이 필요하다 — 그런데 걔들한테는 params가 안 내려온다. 중요한 건 결국 그 layout에 적어 넣게 된 순서에 관한 주석이다.
// Required so server components rendered below see the right locale even
// though they don't re-await `params`. Must come BEFORE any other
// `getTranslations()` / `getMessages()` call.
setRequestLocale(locale);이걸 첫 getMessages() 뒤에 두면, 프리픽스는 제대로 붙은 URL 안에서 기본 locale이 렌더링된다. 에러도 없고 경고도 없다.
2. metadata는 깊게가 아니라 필드 단위로 병합된다
이건 깨진 채로 배포됐고 몇 주 동안 눈치도 못 챈 건이다. 툴체인에서 아무것도 불평하지 않으니까.
증상은 이렇다. 자기 openGraph를 선언한 페이지들이 view-source에서 og:site_name, og:type, og:locale가 빠져 있었다. 그 셋은 위쪽 layout에 넣어둔 것들이다. openGraph.title을 추가한 페이지는 그 위에 얹히는 거라고 넘겨짚었다.
안 얹힌다. Next 자체 문서(node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md)에는 이렇게 적혀 있다.
같은 라우트의 여러 세그먼트에서 export된 metadata 객체들은 얕게 병합되어 해당 라우트의 최종 metadata 출력이 됩니다. 중복된 키는 순서에 따라 교체됩니다.
얕다는 건 openGraph라는 키가 값 하나라는 뜻이다. 그걸 설정한 페이지는 layout의 객체를 통째로 교체한다. twitter도 마찬가지. alternates도 마찬가지 — 그리고 이건 두 번 물렸다. launch-template/src/app/layout.tsx의 루트가 거기서 RSS autodiscovery를 선언하고 있기 때문이다.
alternates: {
types: { "application/rss+xml": FEED_URL },
},그래서 어떤 페이지든 alternates.canonical을 추가하는 순간 — hreflang 때문에 모든 페이지에 필요한 그거 — 그 페이지에서 <link rel="alternate" type="application/rss+xml">가 조용히 사라졌다.
해법은 기억하는 게 아니다. 해법은 base 필드들을 모든 선언이 스프레드하는 헬퍼에 넣어서 떨어뜨릴 수 없게 만드는 것이다. levain-template/src/lib/seo.ts.
export function openGraphBase(lang: Lang): {
type: "website";
siteName: string;
locale: string;
} {
return {
type: "website",
siteName: siteConfig.name,
locale: lang === "es" ? "es_CL" : lang === "fr" ? "fr_FR"
: lang === "pt" ? "pt_PT" : "en_US",
};
}쓸 때는 openGraph: { ...openGraphBase(lang), title, description, url, images }. 같은 파일의 alternates 헬퍼는 정확히 위와 같은 이유로 canonical 옆에 피드 types를 다시 선언한다.
다음에 잡아내는 방법. grep으로 잡힌다. 그 필드를 선언하는 파일은 헬퍼도 참조하고 있어야 한다.
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $faillevain-template에서 12개 파일이 openGraph:를 선언하고, 12개 전부 base를 스프레드한다. CI에서 돌리면 실패 양상이 더 이상 "누가 깜빡했다"가 아니게 된다.
3. 깨끗한 체크아웃에서 tsc --noEmit이 실패한다
증상. 새로 clone, pnpm install, npx tsc --noEmit, 그리고 에러 19개.
src/app/[locale]/pricing/page.tsx(20,10): error TS2304: Cannot find name 'PageProps'.
src/app/[locale]/auth/login/layout.tsx(13,10): error TS2304: Cannot find name 'LayoutProps'.헷갈리는 부분은 이 이름들이 어디서도 import되지 않는다는 점이다. 그래서 쫓아갈 빠진 의존성도 없다. 전역이다. 그리고 생성물이다. next-env.d.ts는 코드 세 줄이다.
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";세 번째 줄이 전부다. PageProps와 LayoutProps는 .next/types/routes.d.ts — build 산출물 — 안에서, 모든 라우트와 그 params를 나열한 ParamMap 인터페이스와 함께 선언된다. (next-env.d.ts 자체도 생성물이고 gitignore되어 있어서, 새로 clone한 상태에서는 읽을 게 아무것도 없다. 단서는 없는 파일에서 시작해 없는 디렉터리를 가리킨다.) 그러니까 이 시그니처의 props.params는 무언가가 생성하기 전까지는 존재하지 않는 파일에서 타입을 받는다.
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;메커니즘은 그 생성 파일 하나만 프로그램에서 제외하고 같은 소스를 타입 체크해서 확인했다. TS2304 19개, 전부 PageProps 아니면 LayoutProps, 다른 에러는 0개.
해법은 존재하는 줄도 몰랐던 일급 커맨드다.
next typegen && tsc --noEmitnext typegen은 전체 build를 돌리지 않고 라우트 타입을 써준다. 문서에 따르면 이게 존재하는 이유가 바로 "라우트 타입이 next dev나 next build 중에만 생성돼서, tsc --noEmit을 직접 돌려도 라우트 타입이 검증되지 않았기" 때문이다. CI가 build 잡과 별도로 타입 체크 잡을 돌리고 있다면, 그 잡은 아예 실패하고 있거나 이전 build가 .next/types에 남겨둔 것을 기준으로 라우트를 검사하고 있는 것이다.
4. 페인트 전 테마 스크립트는 서버에서 렌더링돼야 한다
증상. 테마를 바꾼 적 있는 사람에게는 새로고침할 때마다 엉뚱한 테마가 한 번 번쩍인다.
첫 버전은 클라이언트 컴포넌트였고, useEffect에서 쿠키를 읽어 <html>에 data-theme을 설정했다. 속성은 결국 맞게 되지만 flash는 못 피한다. effect는 hydration 이후에 돌고, 페인트 몇 번은 늦다.
메커니즘은 말로 하고 나면 단순하다. 첫 페인트 전에 도는 건 서버가 문서에 넣어둔 마크업뿐이다. 그래서 이 컴포넌트는 맨 <script>만 내보내는 순수 서버 컴포넌트여야 한다. booking-template/src/components/layout/ThemeBoot.tsx는 그 규칙을 헤더 주석에 달고 있다.
* Rules:
* - Must be a plain server component rendering a bare <script> tag.
* - Never use next/script here: React 19 warns on client-rendered inline scripts.
* - Never render this from a client component.launch-template 쪽은 파일 전체가 아홉 줄이다. "use client"도 없고 import도 없다(쿠키 파싱은 여기서 생략).
export function ThemeBoot({ defaultTheme }: { defaultTheme: "dark" | "light" }) {
const code = `(function(){try{ /* read vs-theme cookie */
document.documentElement.setAttribute('data-theme',v);}catch(e){...}})();`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}루트 layout에서 <head>에 직접 마운트한다. 폰트와 액센트 컬러에도 같은 처리를 해준다.
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" /><html>과 <body>에 붙은 suppressHydrationWarning은 장식이 아니다. 이 스크립트는 React가 DOM을 보기도 전에 <html>의 속성을 바꾸기 때문에, 서버와 클라이언트 마크업은 설계상 진짜로 다르다. 안 붙이면 React가 로드할 때마다 경고를 낸다.
넷 중 셋에 깔려 있는 패턴
metadata 병합, setRequestLocale 순서, 테마 스크립트는 전부 같은 방식으로 실패한다. build 초록불, 타입 초록불, 그리고 브라우저가 받는 게 틀렸다. 셋 중 어느 것도 view-source를 열어보거나, 쿠키를 세팅한 채로 새로고침하거나, 작정하고 직접 짠 grep을 돌리지 않는 한 잡히지 않는다. 생성되는 라우트 타입은 그 점을 뒤집어서 증명하는 예외다 — 얘는 요란하게 실패한다. 다만 평소에 돌리는 커맨드에서가 아닐 뿐이다. 프레임워크가 작업을 생성 파일로 옮기고 metadata를 얕게 병합하기 시작하면, "컴파일됐다"는 더 이상 증거가 아니다.
이게 launch에서 출하하는 패턴이다 — 라이브 데모는 https://launch.violettadev.com.