- バックエンド
- PdM
- 急成長中の福利厚生SaaS
- Other occupations (24)
- Development
- Business
- Other
Next.js Version Skew 真相解明編
Photo by Agence Olloweb on Unsplash
こんにちは。ウォンテッドリーでフロントエンドチームのリーダーをしている原です。
先日「Next.js の Version Skew 問題に向き合う」というタイトルでブログを公開しました。詳細は該当記事に譲りますが、ざっくり内容をお伝えすると次のような内容になっています。
- Next.js アプリケーションにおいて、ブラウザで動作しているバンドル中の
buildId
とサーバーで動作しているアプリケーションのbuildId
が異なると、getServerSideProps
で受け取れるはずのデータが受け取れなくなり、アプリケーションが正常動作しないケースが存在する。 - SPA 遷移する際 router events で
buildId
がマッチするかどうかを確かめて、ミスマッチが起きていたらブラウザリロードをすることで解消できる。
公開後、X にて「通常の Next.js の挙動と異なるのでは。他のところに原因があるのではないか?」 とのご指摘を頂きました。ありがとうございます。
手元で新規に Next.js のアプリケーションを立ち上げ、ビルドを複数回重ねて意図的に version skew を発生させると、前記事で説明していたような getServerSideProps
が 200 OK かつ {}
を返すという現象は発生せず、Next.js がよしなにページをリロードして問題が発生しないようにハンドリングしてくれました。つまり我々のアプリケーションにはまだ別の問題が存在するということです。根本的な問題を解消するために、調査と検証を続けた結果根本的な原因にたどりつくことができたため、本記事ではその結果の共有をします。
tldr
結論を先に述べてしまうと、次の条件を満たしていると前記事のような現象が起きることが分かりました。
- 特定の Next.js のバージョンを使用している
- Next.js の middleware を使用している
次の Next.js の issue にあるような問題がまさに自分たちの環境でも起きていました。
https://github.com/vercel/next.js/issues/57207
解消方法は Next.js のバージョンを 14.1.1 以上に上げることです。
検証
指摘をいただいたあとにすぐに手元で再現を行ってみました。実験に使用した Next.js アプリケーションは https://github.com/chloe463/nextjs-version-skew-experiments で公開しています。
create-next-app
を使い新しい Next.js のアプリケーションを立ち上げ。- 2つのページ
foo
とbar
を用意し、それぞれのページ間を遷移できるようにリンクを用意。 - それぞれのページには
getServerSideProps
を実装し現在時刻を返すように。 - UI 中では
getServerSideProps
で受け取った値をそのまま JSON 文字列として表示するように。
以下は /foo
ページの実装です。bar
の方もほぼ同じなので割愛します。
import { NextPage } from "next";
import Link from "next/link";
import React from "react";
export async function getServerSideProps() {
return {
props: {
time: new Date().toISOString(),
},
};
}
const FooPage: NextPage = (props) => {
return (
<>
<h1>Foo Page</h1>
<Link href="/">Home</Link>
<Link href="/bar">Bar</Link>
<pre>{JSON.stringify(props, null, 2)}</pre>
</>
);
};
export default FooPage;
次の gif 画像のような動きになります。foo, bar を行き来する度に getServerSideProps
から受け取った time
が更新されるのが分かるかと思います。
この2つのページを用意したあとに次のような手順で実験しました。
next build
を実行して、アプリケーションをビルドするnext start
を実行、アプリケーションを立ち上げ、ブラウザでlocalhost:3000/foo
を開く- このときに
/foo
と/bar
間を問題なく遷移できることを確認しておく
- このときに
next start
を止める- 再度
next build
を実行する (このとき 1 とは違う buildId がセットされる) - 2で開いていた
localhost:3000/foo
中にある/bar
へのリンクをクリックして、/bar
に遷移する
手順5で、前記事でも説明した buildId の不一致が起きるため、getServerSideProps
が {}
を返すものと予想していました。しかしながら実際にはそうはならず、404 Not found が返却され、さらにアプリケーションが自動でブラウザをリロードしつつ /bar
へと遷移しました。これは指摘コメントの通りの挙動でした。
したがって、getServerSideProps
が 200OK {}
を返してしまうというのは別のところが原因であることが確定しました。
仮説検証
我々のアプリケーション固有の事情が絡んでいるかもしれないと考えたため、いくつか仮説を立てました。
- 使用している Next.js のバージョンの問題
- Next.js をそのままではなくカスタムサーバーを入れて使用していること
- 他のライブラリとの組み合わせによるもの
- 上記の複合
先ほどのアプリケーションを使って、それぞれの仮説を確かめることにしました。
◇ 1. 使用している Next.js のバージョンの問題
該当のアプリケーションは事情があり古いバージョン (v13系) の Next.js を使っていました。これが原因かもしれないと考え、先述した検証アプリケーションの Next.js のバージョンを 13 系に変えて、同じ操作をしてみました。結果は変わらず自動でブラウザリロードが走り、問題が起きることはありませんでした。
◇ 2. Next.js をそのままではなくカスタムサーバーを入れて使用していること
ウォンテッドリーでは Node.js サーバーに組み込むための middleware (主にトラッキング系) をいれるために、Next.js をそのままではなくカスタムサーバーの上に載せることで動作させています。cf. https://nextjs.org/docs/pages/guides/custom-server
もしかしたらこのカスタムサーバーがリクエストを intercept することで通常の Next.js の挙動と変わってしまうのでは?と考えました。
以下のような簡単なカスタムサーバー実装を入れ、 Express.js を使って動かすようにしてみました。
import express, { Express } from "express";
import { NextServer } from "next/dist/server/next";
import next from "next";
const { PORT = 3000, NODE_ENV } = process.env;
const dev = process.env.NODE_ENV !== "production";
export const ExpressServer = (nextApp: NextServer): Express => {
const app = express();
const handle = nextApp.getRequestHandler();
app.all("/*", async (req, res) => {
// return handle(req, res);
try {
return await handle(req, res);
// return await handle(req as IncomingMessage, res as ServerResponse);
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
return res.status(500).send({ error: err.toString() });
}
});
return app;
};
console.log("start initializing app", { NODE_ENV, PORT });
const nextApp = next({ dev });
nextApp
.prepare()
.then(() => {
const app = ExpressServer(nextApp);
app.listen(PORT, () => {
console.log(`listening on ${PORT}...`);
});
})
.catch((err) => {
console.error(err);
});
この例でも検証セクションで説明した手順を使って再現を試みました。この例でも不具合なく動作しました。(正直これが原因として有力かと考えていたのですこしがっかりしました。)
仮説3の「他のライブラリとのかみ合わせによるもの」も確かめようかと考えましたが、getServerSideProps
やページ間遷移 (Router) に関する他ライブラリで思い当たるものがなかったため、一旦別の手段を考えることにしました。
救世主登場
正直手詰まりだったので、別のアプローチとして生成 AI に頼ることにしました。Claude sonnet 4 に前記事で説明したような不具合の原因調査について相談していると、返答の中の 「ミドルウェアとの相互作用」 という言葉が目にとまりました。該当アプリケーションでも Next.js の middlware を使っているため、ここは深ぼる価値がありそうです。
buildId 不整合を起こした場合、getServerSideProps は 404 となるはずですが、そうならず 200OK `{}` を返してしまいます。 `getServerSideProps` 自体は `not_found` を返しておらず、`{}` を返すような実装にもしていません。 middleware の実装が影響している可能性について詳しく教えて下さい。
上のようなプロンプトを送信してみたところ、「これだ!」と思える issue のリンクを返答してくれました。
Middleware returning empty 200 for old build ID instead of 404 #57207
流れを見ると、どうやら middleware がどんな実装をしているかに関わらず、middleware がある事自体が問題のトリガーでありそうです。検証アプリケーションに空の middleware 実装を追加してみました。
import { NextResponse, NextRequest } from "next/server";
export function middleware(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: "/",
};
これを追加したあと、再度実験してみたところ前記事で説明した現象の再現ができました。つまり getServerSideProps
が 200 OK かつ {}
を返しました。以下の Gif 画像がその様子です。
issue を読むとどうやら Next.js のバージョンを 14.1.1-canary.37 で修正されたようです。つまり Next.js v14.1.1 以降までアップデートしたら解消しそうです。
検証アプリケーションで、実装も middleware もそのままにしつつ v14.2.30 までアップデートして動作を確認したところ不具合が解消されることを確認しました。
解消
ここまでの検証から Next.js を v14 まで上げたら解消できることが分かりました。そうなればやることは一つです。該当アプリケーションの Next.js のバージョンを上げることです。該当アプリケーションは比較的規模が大きく、ここ半年ほど大きな機能開発を立て続けに行っていたため、なかなか Next.js アップデートまで手が回っていない状況でした。しかしウォンテッドリーには月に一回負債返済日という取り組みがあります。通常の開発業務を止めて、技術的な負債の返済のための作業をする取り組みです。この日を使ってメンバーの一人に Next.js アップデートに取り組んでもらいました。またリリース前には QA Squad の手も借りて全体的な動作確認を行ってもらい、無事 v14 までアップデートすることができました。(本当は最新15までアップデートしたいところですが、内部ライブラリがまだ React 19 に対応していなかったりと、まだ道のりは長そうです。)
Next.js v14 に上げた後、前記事で説明した独自のブラウザリロード機能を取り除いて次のような検証を行いました。
- アプリケーションの build を行い、sandbox 環境にデプロイ
- ブラウザで任意のページを開いておく
- もう一度アプリケーションを build して、sandbox 環境へデプロイ
- 2で開いていたページから、別ページ (getServerSideProps が動作するページ) へと遷移
前記事では 4 の際に getServerSideProps
が 200 OK {}
を返してしまい不具合が起きてしまいましたが、Next.js アップデート後のアプリケーションではここで自動でブラウザリロードが挟まり、不具合が起きなくなりました 🎉
まとめ
我々が直面していた Next.js Version Skew 問題は普遍的に起きる問題ではなく、特定の環境下 (特定の Next.js のバージョン、Middleware を使用しているとき) でのみ起こる問題でありました。前記事の時点では特殊な回避方法によって解消していましたが、指摘をもらったことによって本来あるべき姿、 ―Next.js をしっかりアップデートしておくこと― を取ることができたのではと考えています。
発信活動をしたことによってフィードバックを受け取り、そのフィードバックを受けてよりよい改善活動につなげることができたというサイクルは自分の中で一つの成功体験にもなりました。また技術コミュニティの力を非常にありがたく感じています。改めてになりますがご指摘のメッセージありがとうございました。