Zenn

SSGのビルド後に擬似的なダイナミックルーティングを実現してみた

2025/01/27に公開

はじめに

こんにちは、ゆうです。
最近は朝散歩も兼ねて毎日マックで作業しています。
とても居心地が良くフレッシュに働けています。

さて、今回の記事はタイトルの通りSSGのビルド後にダイナミックルーティング的なものを実現する方法について記事にしていきます。

App RouterではgenerateStaticParamsを使うことによってSSGでもダイナミックルーティングを実現できるのですが、、、。

ビルド時に動的な部分を取得しないといけないので、例えばCMSでコンテンツを追加しても表示されないんですよね。今回はこんな課題を解決してみました。

最後まで読んでいければ幸いです。

この記事を読んで学べること

  • SSGでビルドした後に擬似的なダイナミックルーティングを実現できる
  • SSGとCSRを組み合わせたSPAのイメージを掴める
  • 'generateStaticParams'について仕様が学べる
  • <Suspense>の使い方を理解できる
  • useSearchParamsの使い方を理解できる

環境

Next.js v15.1.6
React v19.0.0

背景

サーバーレス環境なしでSSG+S3+CloudFrontならコストも抑えられるし、UX最高のブログが作ろうと考えていました。そして最近microCMSという存在を知って、これ自分で作ってみたいと思いブログとCMSの作成を進めることにしました。

CSMでコンテンツを作成してブログに表示させることを考えると、SSGだけでは実現できないためSSGとCSRを組み合わせて「静的な基盤+動的なデータ取得」のハイブリッド構成でブログを作成しようと決めました。

generateStaticParamsとは

SSGでダイナミックルーティングを実現するときにまず出てくるのがgenerateStaticParamsだと思います。

https://nextjs.org/docs/app/api-reference/functions/generate-static-params

// 静的生成用のパラメータを取得
interface Post {
  id: string;
  // その他の投稿プロパティ...
}

export async function generateStaticParams() {
  // 投稿データ取得API
  const posts: Post[] = await fetch('https://.../posts')
    .then((res) => res.json());

  // 静的生成するパスのパラメータを返却
  // 例: 取得データが [{id: '1'}, {id: '2'}, {id: '3'}] の場合
  // -> /post/1, /post/2, /post/3 の3ページを生成
  return posts.map((post) => ({
    id: post.id,
  }));
}

interface PageProps {
  params: {
    id: string;
  };
}

// 動的ルーティングページコンポーネント
// generateStaticParamsで指定したパラメータに基づき静的生成
export default async function Page({ params }: PageProps) {
  const { id } = params; // パスパラメータから投稿IDを取得
  // 投稿IDを使ったデータ取得処理...
}

ビルド時にAPIを叩いてpostsを取得します。その後、post/1, post2, post3の静的ファイルを作成することによってSSGでダイナミックルーティングを実現しています。

このような処理を行なっているためビルド後にダイナミックルーティングを使って動的にルーティングするページを増やすことができない、、、。

クエリパラメータによる解決

クリパラメータに詳細を表示させたい記事のidを渡すことによってその値を用いてAPIを叩けば解決!
と思い以下のような処理を考えました。

  1. 記事一覧ページ:CSRでレンダリング時にAPIを叩いてお知らせの一覧を取得
  2. 詳細ページ遷移時:記事IDをクエリパラメータ(?id=123)で付与
  3. 詳細ページ側:useSearchParamsでIDを取得→クライアントサイドでAPIフェッチ
  4. 取得データを元に表示

記事一覧ページ:CSRでレンダリング時にAPIを叩いてお知らせの一覧を取得

export default function Posts() {
  const [postsData, setpostsData] = useState<PostsListResponse | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const loadPosts = async () => {
      try {
        const response = await fetchPostsList();
        setPostsData(response);
      } catch (err) {
        setError('記事の取得に失敗しました');
        console.error(err);
      }
    };

    loadPosts();
  }, []);

  return (
    <div className={styles.postsContainer}>
      <h1>記事一覧</h1>
      <ul className={styles.postsList}>
        {postsData.data.map((item) => (
          <PostsListItem key={item.id} posts={item} />
        ))}
      </ul>
    </div>
  );

useEffectを使ってレンダリング時にAPIを叩きます。

詳細ページ遷移時:記事IDをクエリパラメータ(?id=123)で付与

export const PostsListItem = ({ posts }: PostsListItemProps) => {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <li className={styles.postsItem}>
        <Link href={`/posts/detail?id=${posts.id}`}>
          <div>
            <h2>{posts.title}</h2>
            <p className={styles.date}>{posts.date}</p>
            {posts.excerpt && <p className={styles.excerpt}>{posts.excerpt}</p>}
          </div>
        </Link>
      </li>
    </Suspense>
  );
};

クエリパラメータに記事のidを付与してposts/detail?id=1の形でposts/detailに遷移させます。

詳細ページ側:useSearchParamsでIDを取得→クライアントサイドでAPIフェッチ

export default function PostsDetail() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <PostsDetailContent />
    </Suspense>
  );
}
export const PostsDetailContent = () => {
  const [postsData, setPostsData] = useState<PostsDetailResponse | null>(null);
  const [error, setError] = useState<string | null>(null);
  const searchParams = useSearchParams();
  const id = searchParams.get('id');

  useEffect(() => {
    const loadPostsDetail = async () => {
      try {
        const response = await fetchPostsDetail(id as string);
        setPostsData(response);
      } catch (err) {
        setError('記事の取得に失敗しました');
        console.error(err);
      }
    };

    if (id) {
      loadPostsDetail();
    }
  }, [id]);

  const { data: posts } = postsData;

  return (
    <div className={styles.postsDetail}>
      <h1>{posts.title}</h1>
      <p className={styles.date}>{posts.date}</p>
      <div className={styles.content}>{posts.content}</div>
    </div>
  );
}

useSearchParamsは、現在のURLのクエリー文字列を読み取るためのクライアントコンポーネントフックです。
searchParams.get('id')これでクエリパラメータのidを取得しています。
https://nextjs.org/docs/app/api-reference/functions/use-search-params

<Suspense>の役割

Next.jsのビルドプロセスとSuspenseの役割

1. ビルド時の全体フロー

2. 用語の定義

用語 定義
クライアントバンドル ブラウザで実行されるJavaScriptの集合 _next/static/chunks/...
ハイドレーション 静的なHTMLにReactの機能を接続する処理 ReactDOM.hydrate()
プレースホルダー クライアントコンポーネントの位置を示す空要素 <div id="client-root"></div>
fallback 非同期処理中の仮表示コンポーネント <LoadingSpinner />

3. Suspenseの動作をビルドプロセスで解説

ステップ1: サーバーコンポーネントの実行

// サーバーコンポーネント(page.tsx)
export default function Page() {
  const data = fetchData(); // ビルド時に実行
  return (
    <div>
      <StaticContent data={data} />
      <Suspense fallback={<Loading />}>
        <ClientComponent />
      </Suspense>
    </div>
  );
}
  • ビルド時の処理:
    1. fetchData()が実行されデータ取得
    2. <StaticContent>が静的なHTMLに変換
    3. <ClientComponent>の場所にプレースホルダーを生成

ステップ2: クライアントバンドルの生成

# 生成されるファイル例
.next/
└─ static/
   ├─ chunks/
   │  └─ ClientComponent.js # クライアント用コード
   └─ server/pages/page.html # 静的HTML
  • 特徴:
    • <ClientComponent>のロジックはバンドルに含まれる
    • サーバーでは実行されない(ブラウザ専用)

ステップ3: クライアントでのハイドレーション

<!-- 生成されたHTML -->
<div>
  <h1>静的なコンテンツ</h1>
  <div id="suspense-fallback">
    <div class="loading">読み込み中...</div>
  </div>
</div>
  • ハイドレーションの挙動:
    1. 初期表示: fallback(<Loading />)が表示
    2. バックグラウンドでClientComponentをロード
    3. ロード完了後、プレースホルダーを置き換え

4. 各要素の連携イメージ


5. 具体的な例で理解する

ケース: コメント機能付きブログ

// サーバーコンポーネント
export default function BlogPage() {
  const post = await fetchPost(); // SSGでビルド時実行
  
  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<CommentSkeleton />}>
        <CommentsSection /> // クライアントコンポーネント
      </Suspense>
    </article>
  );
}

ビルド時の挙動

  1. fetchPost()で記事データ取得
  2. 記事本文を静的なHTMLに埋め込み
  3. <CommentSkeleton>を静的にレンダリング
  4. <CommentsSection>のコードをクライアントバンドルに追加

クライアントでの挙動

  1. 初期表示: 記事本文 + コメント欄のスケルトンUI
  2. 裏側でCommentsSectionのJavaScriptをダウンロード
  3. ダウンロード完了後、実際のコメント機能が動作開始

6. 重要なポイント整理

  • クライアントバンドル
    「ブラウザ専用の指令書」

    • インタラクティブな処理の設計図を含む
    • サーバーでは触れない「禁断の領域」
  • ハイドレーション
    「静的な人形(HTML)に命を吹き込む儀式」

    • クリックイベントなどの機能を接続
    • 初回ロード時のみ実行
  • プレースホルダー
    「未来のコンテンツのための土地予約」

    • サーバー生成HTML内のマーカー
    • クライアントが中身を構築するための目印
  • fallback
    「工事現場の仮囲い看板」

    • データ準備中の仮表示
    • サーバーとクライアントで共通の表示を保証

7. Suspenseの真の価値

  • 境界線の管理: サーバーとクライアントの責任範囲を明確化
  • 段階的表示: 静的な部分を先に表示しUXを向上
  • エラー防止: クライアント専用コードがサーバーで実行されるのを阻止
// Suspenseの効果を最大化する設計例
<Suspense fallback={<MinimalLoader />}>
  <ClientOnlyFeature />
</Suspense>

<StaticContent /> // サーバーで完全レンダリング

Next.jsのビルドプロセスにおける各要素の連携とSuspenseをまとめてみました。静的なSSGと動的なクライアント機能を安全に組み合わせるための鍵となる仕組みです!
https://react.dev/reference/react/Suspense

まとめ

今回はSSGのビルド後でダイナミックルーティングを実現する方法とSuspenseをまとめてみました。
記事を読んでいただければなんとなく気付いたと思うのですが、Suspenseを使わないとnpm run build時にエラーが出ます。

本当はやりたいことを実現するための物語とそこでの困難(エラー)を物語形式で作成した方が初心者としては理解しやすかったりするんですよね、、、。私がそうでした。

最後まで読んでいただきありがとうございました。
拙い文章だと思いますが今後ともよろしくお願いいたします。

追記

2025/01/28
この記事めっちゃいい!ぜひ読んでください!
https://zenn.dev/akfm/articles/nextjs-partial-pre-rendering
https://zenn.dev/uhyo/articles/react-server-components-multi-stage#一言でreact-server-componentsを理解する

Discussion

ログインするとコメントできます