Menu

Mobile navigation

Next.js では searchParams の扱いにご用心

当ブログは主に Next.js を使って構築している(現時点でのバージョンは 14.2.2)。 先日、トップページから 記事一覧ページ に遷移するときに少しもっさりしている感じがした。

こういった感覚というのは大体合っているもので後から問題が見つかるパターンが多い。 そこで怪しいと思ったキャッシュ関連を調査していると、そもそも記事一覧ページが 動的 Route (SSR の一種)として生成されていることが分かった。

以下がビルド時の出力結果。

Route (app)                                               Size     First Load JS
┌ ○ /                                                     338 B           292 kB
...
├ ƒ /posts                                                475 B           662 kB
├ ● /posts/[slug]                                         490 B           662 kB
├   ├ /posts/p1e0lpm
├   ├ /posts/p18vcqd
├   ├ /posts/p16vfnq
├   └ [+11 more paths]
...
└ ○ /sitemap.xml                                          0 B                0 B
+ First Load JS shared by all                             87 kB
  ├ chunks/313-7c44dae77fe97d18.js                        31.4 kB
  ├ chunks/73ebd006-83408596cd117cc0.js                   53.6 kB
  └ other shared chunks (total)                           1.93 kB
 
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

/posts を見ると ƒ とあり、これは動的 Route で生成されていることを表している(前の記号は λ だったと思うが、定義が変わったのか?)。 現在の記事一覧ページはただのリストを表示させているだけなので動的 Route にするメリットは全くない。

まず、静的 Route を使うと何が嬉しいのか。 それは TTFB(Time To First Byte)が短縮され、高パフォーマンスを出せる点である。

静的 Route であればリクエストが発生する前にサーバがレンダリングを行う。 あらかじめ生成されたキャッシュ(Full Route Cache)をレスポンスするため非常に初期表示が早い。

もし、これが動的 Route だとキャッシュを即座にレスポンスせずにサーバでレンダリングしてからレスポンスするため若干ラグが発生する。

では、なぜ静的 Route で生成されるべきページが動的 Route になっていたのか。

初めはどこかのコンポーネントでデータ取得を行っているのではないかと考えた。 全てのコンポーネントを辿ってみたが、そういった箇所はなかった。

消去法で generateMetadata をコメントアウトしてからビルドすると以下のような出力結果になった。

Route (app)                                               Size     First Load JS
┌ ○ /                                                     338 B           292 kB
...
├ ○ /posts                                                475 B           666 kB
├ ● /posts/[slug]                                         490 B           666 kB
├   ├ /posts/p1e0lpm
├   ├ /posts/p18vcqd
├   ├ /posts/p16vfnq
├   └ [+11 more paths]
...
└ ○ /sitemap.xml                                          0 B                0 B
+ First Load JS shared by all                             87 kB
  ├ chunks/313-7c44dae77fe97d18.js                        31.4 kB
  ├ chunks/73ebd006-83408596cd117cc0.js                   53.6 kB
  └ other shared chunks (total)                           1.93 kB
 
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

/posts が静的 Route として生成されている)

generateMetadata 自体は動的 Route にスイッチさせるような関数ではない。 意外な結果なのだが、searchParams を参照すると強制的に動的 Route になってしまうようである。

page.tsx で渡される paramsgenerateStaticParams1 で指定すれば SSG にできるが、searchParams は指定できない。 それゆえ、searchParams を使うだけでは動的 Route にはならないという思い込みがあったのが今回の反省だ。

元の generateMetadata は以下のようになっていた。

page.tsx

export const generateMetadata = ({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) => {
  const category = (searchParams.category as string) ?? 'blog';
  return {
    title: `${category.charAt(0).toUpperCase()}${category.slice(1)}`,
    description: 'Blog posts of Keisuke Hayashi.',
    alternates: {
      canonical: '/posts',
    },
    openGraph: {
      url: '/posts',
    },
  } satisfies Metadata;
};

これはパラメータの値によってページタイトルを動的に変更したくてこの形にした。 例えば https://kkhys.me/posts?category=tech という URL であればタイトルは Tech になる。

結果的に対症療法として以下のようにメタデータを設定することにした。

page.tsx

export const metadata = {
  title: 'Blog',
  description: 'Blog posts of Keisuke Hayashi.',
  alternates: {
    canonical: '/posts',
  },
  openGraph: {
    url: '/posts',
  },
} satisfies Metadata;

静的 Route かつページタイトルを動的に変更したければ https://kkhys.me/posts/tech のような URL にするしかない。 これだとあまり美しくないから URL 設計を見直さなければならない。

Next.js は便利だが、こういった規約を強制させられる点がイマイチなところだ。 今後 generateStaticParamssearchParams を設定できるようになると良いけど、その可能性は低いかもしれない。

コンポーネントで searchParams の値が必要な場合は page.tsx の props から引っ張ってくるのではなく、useSearchParams を使って CSR でレンダリングする方が場合によっては有効。

パラメータの扱い方についてまとめると、searchParams を使うとページは動的 Route にオプトインされる。 コンテンツはビルド時ではなくリクエスト時にレンダリングされるため、CDN を使ってレスポンスを高速化できない。

searchParams の代わりに useSearchParams を使うと部分的にクライアントコンポーネントになり、ページは静的 Route にオプトインされる。 CDN を使った高速化が可能だが、リクエストごとにクライアント上でハイドレーションが必要になる。

今回の件については Reddit が参考になった。 最近は Stack Overflow よりも reddit の方が有益な情報が落ちていることが多いように感じる。

また、Next.js リポジトリのこの Issue も参考になる。

Footnotes

  1. generateStaticParams 関数を使うと指定されたパスパラメータはビルド時に SSG が実行される。逆に指定しなければリクエスト時に SSG が実行されるのでできるだけ指定した方が良い