2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NextJSのSSGが、"いわゆるSSG"とは限らない件

Posted at

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

Screenshot 2024-07-08 at 10.03.46.png

この状態では、ブラウザをハードリロードすると時間を再度取得し直しますが
ビルドして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と出力しており、静的なコンテンツが生成されていそうです。

以下のようにcookiesheaderssearchParamsにアクセスすると、逆に静的コンテンツとして扱われなくなります。

Dynamic Functions

// 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を指定することでビルド時にルートを自動で生成してくれます。

Dynamic Routes

// 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) あり💣
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?