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.tsxpropsから引っ張ってくるのではなく、useSearchParams使ってCSRでレンダリングする方が場合によっては有効。

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

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

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

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


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