かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Next.js SSG したサイトを別ドメインから参照させててハマった

あるプロジェクトで Page routing の Next.js を SSG で作成したサイトを Vercel でホスティングしたものを CloudFront と Lambda@Edge を使って別ドメインの URL からサイトを表示させるようにしていた。
図で書くとこんな感じ サービス構成

CloudFront を使った別ドメイン (b.com/foo) からサイトを見たときだけページ内リンク (next/link) で遷移時にアプリがクラッシュしてしまう問題が発生した。

Next.js error: a client-side exception has occurred

すっごくレアケースだと思うけど記録としてメモしておく

環境

  • 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.jsoutput: "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 として使ったためにページがクラッシュしてしまっていたというオチ

解決方法

  1. next/link の箇所が SPA として動作するので、next/link を辞めて a タグを使う
  2. /_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ドメイン付きに強制的に置換してしまう方法は可能だった。

// next.config.js
const isProd = process.env.VERCEL_ENV === 'production';

const nextConfig = {
  reactStrictMode: true,
  // souce map の出力
  productionBrowserSourceMaps: isProd ? false : true,
  // webpack の設定を追加
  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パターンがあった

  1. Serverless Function でレスポンスに header を追加する
  2. middleware (/src/middleware.js) でレスポンスに header を追加する
  3. next.config.js に headers オプションを追加する
  4. vercel.jsonheaders オプションを追加する

cf.

今回はシンプルそうな 4 の vercel.json に headers を追加することで CORS が発生しなくなったのでそれ以外の調査はしていない

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.jsonAccess-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 は正常に行えている)

謎…

dev tool でエラーの箇所のコードを見ていたら preview mode の場合は cookie が必要だと書かれたコメントを発見した

function fetchRetry(
  url: string,
  attempts: number,
  options: Pick<RequestInit, 'method' | 'headers'>
): Promise<Response> {
  return fetch(url, {
    // Cookies are required to be present for Next.js' SSG "Preview Mode".
    // Cookies may also be required for `getServerSideProps`.
    //
    // > `fetch` won’t send cookies, unless you set the credentials init
    // > option.
    // https://developer.mozilla.org/docs/Web/API/Fetch_API/Using_Fetch
    //
    // > For maximum browser compatibility when it comes to sending &
    // > receiving cookies, always supply the `credentials: 'same-origin'`
    // > option instead of relying on the default.
    // https://github.com/github/fetch#caveats
    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 なんや?このマンガ!?