params は Promise になり、metadata はフィールド単位でマージされて深くはマージされず、PageProps/LayoutProps は何かがそれを生成するまで存在せず、ペイント前に走るテーマスクリプトはサーバーレンダリングでなければならない。 4 つのうち 3 つは何も言わずに壊れる。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;時間を食ったのは二次的な影響のほうだ。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 の中でデフォルトロケールがレンダリングされる。エラーもなければ警告もない。
2. metadata はフィールド単位でマージされる。深くはマージされない
これは壊れたまま出荷してしまい、何週間も気づかなかったやつだ。ツールチェインの何ひとつ文句を言わないからだ。
症状はこう。自前の openGraph を宣言しているページで、view-source を見ると og:site_name、og:type、og:locale が抜けている。その 3 つは上位の layout に書いてあった。openGraph.title を足したページはその上に重なるものだと思い込んでいた。
重ならない。Next 自身のドキュメント(node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md)にはこうある。
同一ルート内の複数セグメントからエクスポートされた metadata オブジェクトは、浅くマージされてそのルートの最終的な metadata 出力になります。重複したキーは順序に基づいて置き換えられます。
浅いというのは、openGraph というキーが値 1 個だということだ。それをセットしたページは layout のオブジェクトをまるごと置き換える。twitter も同じ。alternates も同じ — そしてこれには 2 回やられた。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"> が黙って消えていた。
直し方は「忘れないようにする」ではない。ベースになるフィールドを、すべての宣言がスプレッドするヘルパーに入れて、落とせなくすることだ。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 個すべてがベースをスプレッドしている。これを CI で走らせれば、失敗のしかたが「誰かが忘れた」ではなくなる。
3. クリーンな checkout で tsc --noEmit が落ちる
症状。クローンしたて、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 はコード 3 行しかない。
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";3 行目がすべてだ。PageProps と LayoutProps は .next/types/routes.d.ts — つまり build 成果物 — の中で、全ルートとその params を並べた ParamMap インターフェイスと一緒に宣言されている。(next-env.d.ts 自体も生成物で gitignore されているので、クローンしたてでは読むものが何もない。手がかりは、手元にないファイルから始まって、手元にないディレクトリを指している。)つまりこのシグネチャの props.params は、何かが生成するまで存在しないファイルから型が付いている。
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;仕組みは、その生成ファイル 1 つだけをプログラムから外して同じソースを型チェックして確認した。TS2304 が 19 個、全部 PageProps か LayoutProps、それ以外のエラーはゼロ。
直し方は、存在すら知らなかった一級のコマンドだ。
next typegen && tsc --noEmitnext typegen は、フルの build を走らせずにルートの型を書き出す。ドキュメントによれば、これが存在するのはまさに「ルートの型は next dev か next build の間にしか生成されず、つまり tsc --noEmit を直接走らせてもルートの型は検証されなかった」からだ。CI が build ジョブとは別に型チェックのジョブを走らせているなら、そのジョブは真っ赤に落ちているか、以前の build が .next/types に残していったもの相手にルートをチェックしているかのどちらかだ。
4. ペイント前のテーマスクリプトはサーバーレンダリングでなければならない
症状。テーマを切り替えたことのある人には、リロードのたびに違うテーマが一瞬フラッシュする。
最初のバージョンはクライアントコンポーネントで、useEffect で cookie を読んで <html> に data-theme をセットしていた。属性は最終的に正しくなるが、フラッシュは避けられない。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 のほうはファイル全体で 9 行。"use client" もなければ import もない(cookie のパースはここでは省略)。
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 は読み込みのたびに警告を出す。
4 つのうち 3 つに共通するパターン
metadata のマージ、setRequestLocale の順序、そしてテーマスクリプトは、どれも同じ壊れ方をする。build は緑、型も緑、そしてブラウザに届くものが間違っている。この 3 つはどれも、view-source を開くか、cookie をセットした状態でリロードするか、自分で意図して書いた grep を走らせるか、そこまでやらないと捕まらない。生成されるルートの型は、その点を裏側から証明する例外だ — こちらはちゃんと大きな音を立てて落ちる。ただし普段走らせるコマンドの中ではない。フレームワークが仕事を生成ファイルに移し、metadata を浅くマージするようになったとき、「コンパイルは通った」は証拠ではなくなる。
これが launch で出荷しているパターンだ — ライブデモは https://launch.violettadev.com。