NextJSのSSGが、"いわゆるSSG"とは異なる件
はじめに
こんにちは。最近NextJSを触る機会が仕事でも、プライベートでも増えてきました。NextJSの機能の豊富さには驚かされるばかりですが、その中でNextJSのSSGが、"いわゆるSSG"とは異なる挙動を示すことがあり、少し戸惑うことがありましたのでそのことを記事にしたいと思います。具体的には、NextJSビルド時にSSGとログで表示されているのに、サーバーでの処理が必要になるというケースがありました。自分でホスティングする場合などは、影響しそうです。
環境
今回取り上げる記事の内容で利用している主な各ライブラリなどのバージョンです。
仕様が目まぐるしく変わっているので、記事の内容とずれている場合はご確認ください。
Name | Version |
---|---|
react | 18.3.1 |
next | 14.2.4 |
typescript | 5.5.3 |
以下のコマンドでProjectを開始しています。
npx create-next-app@latest
この記事では、そのほか特別な設定などはしていません。
"いわゆるSSG"とは
"いわゆるSSG"について確認しておきます。SSGはStatic Site Generationの略で、ビルド時にウェブページを静的ファイルとして生成する手法を指します。この方法では、あらかじめ定義されたコンテンツがHTMLファイルとして生成され、サーバーから直接提供されるため、リクエストごとにサーバーサイドでの処理が不要になります。結果として、ページのロードが高速になり、SEOの効果やユーザー体験が向上します。
もちろんNextJSはSSGサポート
もちろん、NextJSもSSGをサポートしています。
App Routerになってからは、関数内で非同期関数を扱うことが可能になり、ビルド時にデータを取得し、静的ページを生成します。これにより高速なパフォーマンスとSEOの改善が期待できます。さらに、NextJSのApp Routerでは、コンポーネント内で非同期関数を使うことができます。以下は、現在時刻をAPIから取得して表示するだけの簡単なサンプルを例にしようと思います。
// src/app/page.tsx
async function getCurrentTime() {
const res = await fetch("http://worldtimeapi.org/api/timezone/Etc/UTC");
if (!res.ok) {
throw new Error(`Failed to fetch the current time`);
}
const data = await res.json();
return data.datetime;
}
export default async function StaticPage() {
const currentTime = await getCurrentTime();
return (
<div>
<h1>Current Time</h1>
<p>{currentTime}</p>
</div>
);
}
実行すれば現在時刻の画面が表示されます。
pnpm run dev
この状態では、ブラウザをハードリロードすると時間を再度取得し直しますが
ビルドしてNext Serverを起動すると、ビルド時の時間が保存されるため、ハードリロードしようとサーバーを再起動しても時間がかわることはありません。
% pnpm run build
> ssg-example@0.1.0 build /Users/kohei/Workspace/next/ssg-example
> next build
▲ Next.js 14.2.4
Creating an optimized production build ...
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages (5/5)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 138 B 87.1 kB
└ ○ /_not-found 870 B 87.9 kB
+ First Load JS shared by all 87 kB
├ chunks/221-412c466386c2d029.js 31.5 kB
├ chunks/67cfe1a8-964e1781a2edea35.js 53.6 kB
└ other shared chunks (total) 1.86 kB
○ (Static) prerendered as static content
% pnpm run start
> ssg-example@0.1.0 start /Users/kohei/Workspace/next/ssg-example
> next start
▲ Next.js 14.2.4
- Local: http://localhost:3000
✓ Starting...
✓ Ready in 172ms
またビルドログにも、(Static) prerendered as static content
と出力しており、静的なコンテンツが生成されていそうです。
以下のようにcookies
やheaders
、searchParams
にアクセスすると、逆に静的コンテンツとして扱われなくなります。
// src/app/dynamic/page.tsx
import { cookies } from "next/headers";
export default async function UseCookiePage() {
const cookieStore = cookies();
const cookieValues = cookieStore.getAll();
return (
<div>
<h1>Cookie</h1>
<p>{JSON.stringify(cookieValues)}</p>
</div>
);
}
以下はcookies
を利用したルート(/use-cookie
)を含む場合のビルド結果です。
Route (app) Size First Load JS
┌ ○ / 141 B 87.1 kB
├ ○ /_not-found 870 B 87.9 kB
└ ƒ /use-cookie 141 B 87.1 kB
+ First Load JS shared by all 87 kB
├ chunks/221-412c466386c2d029.js 31.5 kB
├ chunks/67cfe1a8-964e1781a2edea35.js 53.6 kB
└ other shared chunks (total) 1.86 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
ユーザーごとに差がないルートは静的なコンテンツに、cookieなどユーザーごとに差がある要素がある場合は動的なコンテンツになります。
続いて、ダイナミックルーティングを利用した場合の挙動を見てみます。
generateStaticParams
でpathを指定することでビルド時にルートを自動で生成してくれます。
// src/app/dynamic-route/[key]/page.tsx
export async function generateStaticParams() {
const res = await fetch("http://worldtimeapi.org/api/timezone/Etc/UTC");
if (!res.ok) {
throw new Error(`Failed to fetch the current time`);
}
const data = await res.json();
return Object.keys(data).map((key) => ({
key: key.toLowerCase(),
}));
}
type DynamicRoutePageProps = {
params: { key: string };
};
export default async function DynamicRoutePage({ params }: DynamicRoutePageProps) {
const { key } = params;
return (
<div>
<h1>{key}</h1>
</div>
);
}
Route (app) Size First Load JS
┌ ○ / 146 B 87.1 kB
├ ○ /_not-found 870 B 87.9 kB
├ ● /dynamic-route/[key] 150 B 87.1 kB
├ ├ /dynamic-route/abbreviation
├ ├ /dynamic-route/client_ip
├ ├ /dynamic-route/datetime
├ └ [+12 more paths]
└ ƒ /use-cookie 150 B 87.1 kB
+ First Load JS shared by all 87 kB
├ chunks/221-412c466386c2d029.js 31.5 kB
├ chunks/67cfe1a8-964e1781a2edea35.js 53.6 kB
└ other shared chunks (total) 1.86 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
dynamic
以下にWorldTimeApi
のレスポンスオブジェクトのkeyでルートが生成されました。
想定外なNextJSのSSG
ここまでは、静的なコンテンツ、動的なコンテンツともに想定通りに動作しています。
では先ほどの、ダイナミックルーティングの中でcookie
にアクセスするとどうなりそうでしょうか?cookieはリクエストごとに異なる情報であるため、動的にならざるをえないはずです。
では/dynamic-route-use-cookie
以下にpageを作成してみます。
// src/app/dynamic-route-use-cookie/page.tsx
import { cookies } from "next/headers";
export async function generateStaticParams() {
const res = await fetch("http://worldtimeapi.org/api/timezone/Etc/UTC");
if (!res.ok) {
throw new Error(`Failed to fetch the current time`);
}
const data = await res.json();
return Object.keys(data).map((key) => ({
key: key.toLowerCase(),
}));
}
type DynamicRouteUseCookiePageProps = {
params: { key: string };
};
export default async function DynamicRouteUseCookiePage({ params }: DynamicRouteUseCookiePageProps) {
const { key } = params;
const cookieStore = cookies();
const cookieValues = cookieStore.getAll();
return (
<div>
<h1>{key}</h1>
<p>{JSON.stringify(cookieValues)}</p>
</div>
);
}
その状態でのビルド結果がこちらです。
Route (app) Size First Load JS
┌ ○ / 150 B 87.1 kB
├ ○ /_not-found 870 B 87.9 kB
├ ● /dynamic-route-use-cookie/[key] 150 B 87.1 kB
├ ├ /dynamic-route-use-cookie/abbreviation
├ ├ /dynamic-route-use-cookie/client_ip
├ ├ /dynamic-route-use-cookie/datetime
├ └ [+12 more paths]
├ ● /dynamic-route/[key] 150 B 87.1 kB
├ ├ /dynamic-route/abbreviation
├ ├ /dynamic-route/client_ip
├ ├ /dynamic-route/datetime
├ └ [+12 more paths]
└ ƒ /use-cookie 150 B 87.1 kB
+ First Load JS shared by all 87 kB
├ chunks/221-412c466386c2d029.js 31.5 kB
├ chunks/67cfe1a8-964e1781a2edea35.js 53.6 kB
└ other shared chunks (total) 1.86 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
なんと/dynamic-route-use-cookie/[key]
も/dynamic-route/[key]
と同様、SSGであるというログが出力されています。
では実際にサーバーを起動したときの挙動はどうでしょうか?Cookieによる動的な部分はどう扱われるのでしょうか。
比較のため各ルートにログを追加して挙動を確認します。
- Dynamic Routeなし、Cookieアクセスなし
// src/app/page.tsx
export default async function StaticPage() {
const currentTime = await getCurrentTime();
console.log("StaticPageが実行されました。"); // ← ログを追加
return (
<div>
<h1>Current Time</h1>
<p>{currentTime}</p>
</div>
);
}
- Dynamic Routeなし、Cookieアクセスあり
// src/app/use-cookie/page.tsx
export default async function UseCookiePage() {
const cookieStore = cookies();
const cookieValues = cookieStore.getAll();
console.log("UseCookiePageが実行されました。"); // ← ログを追加
return (
<div>
<h1>Cookie</h1>
<p>{JSON.stringify(cookieValues)}</p>
</div>
);
}
- Dynamic Routeあり、Cookieアクセスなし
// src/app/dynamic-route/[key]/page.tsx
export default async function DynamicRoutePage({ params }: DynamicRoutePageProps) {
const { key } = params;
console.log("DynamicRoutePageが実行されました。"); // ← ログを追加
return (
<div>
<h1>{key}</h1>
</div>
);
}
- Dynamic Routeあり、Cookieアクセスあり
// src/app/dynamic-route-use-cookie/page.tsx
export default async function DynamicRouteUseCookiePage({
params,
}: DynamicRouteUseCookiePageProps) {
const { key } = params;
const cookieStore = cookies();
const cookieValues = cookieStore.getAll();
console.log("DynamicCookiesPageが実行されました。"); // ← ログを追加
return (
<div>
<h1>{key}</h1>
<p>{JSON.stringify(cookieValues)}</p>
</div>
);
}
まずビルド時に実行されたのは、Dynamic Routeあり、CookieアクセスなしとDynamic Routeなし、Cookieアクセスなし のみでした。Cookieにアクセスしたルートは、ビルド時は実行されません。
ビルド・サーバーログ
% pnpm run build
> ssg-example@0.1.0 build /Users/kohei/Workspace/next/ssg-example
> next build
▲ Next.js 14.2.4
Creating an optimized production build ...
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
Generating static pages (0/36) [ ]DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
DynamicRoutePageが実行されました。
StaticPageが実行されました。
StaticPageが実行されました。
✓ Generating static pages (36/36)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 150 B 87.1 kB
├ ○ /_not-found 870 B 87.9 kB
├ ● /dynamic-route-use-cookie/[key] 150 B 87.1 kB
├ ├ /dynamic-route-use-cookie/abbreviation
├ ├ /dynamic-route-use-cookie/client_ip
├ ├ /dynamic-route-use-cookie/datetime
├ └ [+12 more paths]
├ ● /dynamic-route/[key] 150 B 87.1 kB
├ ├ /dynamic-route/abbreviation
├ ├ /dynamic-route/client_ip
├ ├ /dynamic-route/datetime
├ └ [+12 more paths]
└ ƒ /use-cookie 150 B 87.1 kB
+ First Load JS shared by all 87 kB
├ chunks/221-412c466386c2d029.js 31.5 kB
├ chunks/67cfe1a8-964e1781a2edea35.js 53.6 kB
└ other shared chunks (total) 1.86 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
% pnpm run start
> ssg-example@0.1.0 start /Users/kohei/Workspace/next/ssg-example
> next start
▲ Next.js 14.2.4
- Local: http://localhost:3000
✓ Starting...
✓ Ready in 166ms
UseCookiePageが実行されました。
DynamicCookiesPageが実行されました。
サーバー起動後にすべてのパスにアクセスしたところ、Dynamic Routeなし、Cookieアクセスあり とDynamic Routeあり、Cookieアクセスありのみがサーバーでログを出力しました。
ビルドログでは(SSG) prerendered as static HTML (uses getStaticProps)
と出力されるにも関わらず、実際にはサーバーで処理をしているという結果です。もしパフォーマンスを気にしいてる場合は、ビルドログのみでは判断できないのでかなり危険な仕様に思えます。
結論
Dynamic Routeあり、Cookieアクセスありの場合、ビルドログにSSGと表示されるにも関わらず、それが"いわゆるSSG"でない可能性を考慮しないといけないというお話でした。
最後に結果を表にまとめとときます。
Cookieアクセス※ | Dynamic Route | ビルド結果 | サーバー処理 |
---|---|---|---|
❌ | ❌ | (Static) prerendered as static content | なし |
⭕️ | ❌ | (Dynamic) server-rendered on demand | あり |
❌ | ⭕️ | (SSG) prerendered as static HTML (uses getStaticProps) | なし |
⭕️ | ⭕️ | (SSG) prerendered as static HTML (uses getStaticProps) | あり💣 |