あるプロジェクトで Page routing の Next.js を SSG で作成したサイトを Vercel でホスティングしたものを CloudFront と Lambda@Edge を使って別ドメインの URL からサイトを表示させるようにしていた。
図で書くとこんな感じ
CloudFront を使った別ドメイン (b.com/foo
) からサイトを見たときだけページ内リンク (next/link) で遷移時にアプリがクラッシュしてしまう問題が発生した。
すっごくレアケースだと思うけど記録としてメモしておく
環境
- Next 13.5.4
- React 18.2.0
- TypeScript 5.2.2
結論 /_next/data
へのアクセスに問題があった
Next.js の SSG (Static Site Generation) は Static Exports ではない
今回の問題は Next.js の SSG は常に静的な HTML を配信するだけものだと勘違いしていた点にあった。
dev took の Network タブで観察してみた結果、Vercel でホスティングしている Next.js の SSG されたアプリは初回アクセスは生成された HTML が帰るがクライアントサイドで react 化され、アプリ内リンクなどは staticProps となる JSON を preftech で取得し、その JSON を元にページの再レンダリングを行っていてた。 SSG したのだから HTML だけで完結しているのでは?と思いドキュメントを読んで勘違いしていたことに気がついた
Statically generates both HTML and JSON
When a page with getStaticProps
is pre-rendered at build time, in addition to the page HTML file, Next.js generates a JSON file holding the result of running getStaticProps
.
This JSON file will be used in client-side routing through next/link
or next/router
. When you navigate to a page that’s pre-rendered using getStaticProps
, Next.js fetches this JSON file (pre-computed at build time) and uses it as the props for the page component. This means that client-side page transitions will not call getStaticProps
as only the exported JSON is used.
cf. Data Fetching: getStaticProps | Next.js
Static Exports
Next.js enables starting as a static site or Single-Page Application (SPA), then later optionally upgrading to use features that require a server.
Since Next.js supports this static export, it can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets.
cf. Deploying: Static Exports | Next.js
next.config.js
に output: "export"
オプションを指定していると build 時に out
ディレクトリに HTML が出力されるが、これは Static Exports であり SSG ではなく、Static Export は完全に静的な HTML を生成するものではなく使っている機能によっては SPA として動作するのだと解った。
今回のサイトでは getStaticPaths
を使ったダイナミックルーティングを行い、ページで表示する内容を getStaticProps
で事前に取得するようにしていたので、各ページの HTML と共に各ページの static props になる JSON が出力されており、サイトは初回アクセス以降はクライアントサイドで SPA となりページ内リンクでは JSON を取得してページを再レンダリングしているという事だった。
(static props となる JSON が取得できないとか、next/link を使わず a タグでリンクした場合は通常のページ遷移になるのだと思うが検証していない)
static props の JSON は /_next/data
内に格納されている
SSG で build されたサイトは static props となるデータが /_next/data/{BUILD_ID}
内に ページ名.json
の形で出力されていた。
SSG されたサイトが SPA のように振る舞うとき、その json を取得してそれを元に画面を再レンダリングしており、CloudFront 側のドメイン (b.com
) からアクセスした際に /_next/data
で ferch が走っていたために、コードの存在しないドメイン宛の b.com/_next/data
にリクエストが飛んでいた。
本来存在しないリクエストなのでエラーになるはずだが、今回のケースでは status code 200 が返っていたために SPA としてページを更新しようとして不正なレスポンスを static props として使ったためにページがクラッシュしてしまっていたというオチ
解決方法
next/link
の箇所が SPA として動作するので、next/link
を辞めて a
タグを使う
/_next/data
のパスに実際のコードのあるドメイン (origin) を追加する
next/link
を使わない方法はシンプルにタグを置き換えればよいだけなので、今回は /_next/data
にドメインを追加する方法を試してみた
ビルドされた /_next/data
のパスにドメイン (origin) を追加する
assetPrefix
というオプションがあるが、これは static ファイル (.next/static
or /out/_next/static
) に対してのみ有効で /_next/data
へのアクセスにドメインを追加することはできない
While assetPrefix
covers requests to _next/static
, it does not influence the following paths:
- Files in the public folder; if you want to serve those assets over a CDN, you'll have to introduce the prefix yourself
/_next/data/
requests for getServerSideProps
pages. These requests will always be made against the main domain since they're not static.
/_next/data/
requests for getStaticProps
pages. These requests will always be made against the main domain to support Incremental Static Generation, even if you're not using it (for consistency).
cf. next.config.js Options: assetPrefix | Next.js
/_next/data
にドメインを設定できる dataPathPrefix
というアイディアも提案されていたようだが App Router になれば解決するとして採用はされていないようだった
assetPrefix
に似たオプションで basePath
というものがあるが、これはあるドメインの別階層に Next のアプリをデプロイした場合にパスを設定できるオプションで /
始まりしか許容されておらずドメインを追加することは出来なかった
string-replace-loader
を使ってビルド時にパスを置換する
Next.js の提供しているオプションでは /_next/data
にドメインを追加することは不可能そうだった。
Next.js のビルドには webpack が使われているので、ビルド時に文字列を置換する string-replace-loader
ローダーを使って /_next/data
をドメイン付きに強制的に置換してしまう方法は可能だった。
const isProd = process.env.VERCEL_ENV === 'production';
const nextConfig = {
reactStrictMode: true,
productionBrowserSourceMaps: isProd ? false : true,
webpack: (config) => {
config.module.rules.push(
...[
isProd
? {
test: /.*.js$/,
loader: 'string-replace-loader',
options: {
search: '/_next/data',
replace: `${ORIGIN.replace(/\/$/, '')}/_next/data`,
},
}
: undefined
].filter(Boolean)
);
},
}
これで /_next/data
内にある json を取得するドメインを変更することが出来た (hack的なので漏れや他のバグを産み出すある可能性はあるが)
CORS 問題
ユーザーがアクセスするドメインは b.com
で実際のコードが置かれているのが a.com
。Next のアプリが裏側で fetch しようとした場合 b.com
から a.com/_next/data/{BUILD_ID}/**/*.json
にアクセスしようとするので、CORS 問題が発生する
検索していると CORS を許可する方法として下記の4パターンがあった
- Serverless Function でレスポンスに header を追加する
- middleware (
/src/middleware.js
) でレスポンスに header を追加する
next.config.js
に headers オプションを追加する
vercel.json
に headers オプションを追加する
cf.
今回はシンプルそうな 4 の vercel.json
に headers を追加することで CORS が発生しなくなったのでそれ以外の調査はしていない
verce.json
{
"headers": [
{
"source": "/_next/data/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Credentials", "value": "true" },
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{ "key": "Access-Control-Allow-Methods", "value": "GET" },
{
"key": "Access-Control-Allow-Headers",
"value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-Nextjs-Data"
}
]
}
]
}
verce.json
の Access-Control-Allow-Origin
は whitelist のように複数のオリジンを配列で渡すことが出来なかった (build error になる) ので *
として、メソッドを GET
に制限することにした
これで CloudFront 経由の b.com
からアクセスしても next/link
が正常に動作して SPA のように json でページが更新されるようになった!!!
preview モードで prefetch だけが CORS になる問題
ここまでで 9割問題ないのだが、Vercel の preview モードでホスティングしているサイト (ステージング環境) も CloudFront 経由でアクセスできるようになっており、preview モードのサイトを CloudFront 経由の別ドメインでアクセスした時だけ /next/link
の preftech が CORS のエラーになっていた。 (画面更新のための JSON の GET は正常に行えている)
謎…
Vercel の Preview モードの fetch には cookie が必要
dev tool でエラーの箇所のコードを見ていたら preview mode の場合は cookie が必要だと書かれたコメントを発見した
function fetchRetry(
url: string,
attempts: number,
options: Pick<RequestInit, 'method' | 'headers'>
): Promise<Response> {
return fetch(url, {
credentials: 'same-origin',
method: options.method || 'GET',
headers: Object.assign({}, options.headers, {
'x-nextjs-data': '1',
}),
}).then((response) => {
https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/router.ts#L425-L447
恐らく Vercel の Preview モードは本来コメントを残したりする機能が付いているので vercel にログインしている / アクセス可能であるという cookie が必要になっているのではないかと思った。
preftech がエラーになってもステージング環境だけだし、ページの遷移も問題なく行えているのでこの件に関しては対応しないものとした。
所管
SSG と static exports を混同していたことや、SSG すれば HTML しか使ってないのような勘違いもあったが、本来エラーになってほしいリクエストが 200 番で返されていたために予期せぬクラッシュが発生してしまっていた。
Next.js をビルドした状態でかつ本番環境でしか発生していない問題だったので、フレームワークをいい感じにしてくれるブラックボックスとして扱っていたために調査が難しく時間がかかる結果となった。
また Next.js はいろいろなオプションが用意されているが /_next/data
のドメインを変更する方法が無いなど、別のドメインから参照させるような使い方はあまり想定してないようにも思った。
教訓
フレームワークをドキュメントにないような使い方をする時はフレームワークの挙動についてよく理解しておかないとバグを踏む!
ただのバグ報告が大作になってしまった…
おわり
[参照]
どういう意味でで SSG なんや?このマンガ!?