1
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?

Next.jsのデータフェッチング — SSR/SSG/ISRをいつ使うか整理した

1
Posted at

はじめに

Next.jsを触っていて一番迷ったのがデータフェッチの戦略。

「SSRとSSGとISRって結局どう使い分けるの?」という疑問が最初からずっとあった。PHPはリクエストのたびにHTMLを生成するので「SSR一択」だったが、Next.jsは戦略を選べる分、判断が必要になる。

App Routerでの書き方を中心に、それぞれの使い所を整理した。


4つのレンダリング戦略

CSR(Client Side Rendering)
  ブラウザでデータを取得してレンダリング
  → useEffect + fetch

SSR(Server Side Rendering)
  リクエストのたびにサーバーでHTMLを生成
  → cache: 'no-store'

SSG(Static Site Generation)
  ビルド時にHTMLを生成して使い回す
  → cache: 'force-cache'(デフォルト)

ISR(Incremental Static Regeneration)
  SSGの結果をN秒後に再生成する
  → next: { revalidate: N }

PHPはSSRしかないが、Next.jsは4つから選べる。「どの戦略を使うか」はページの性質によって変わる。


戦略ごとの書き方

SSR — リクエストのたびに最新データを取得

// app/orders/page.tsx
// キャッシュなし → 毎回FastAPIに問い合わせる

export default async function OrdersPage() {
    const res = await fetch(`${process.env.FASTAPI_URL}/orders`, {
        cache: 'no-store',  // SSR
    });
    const orders = await res.json();

    return (
        <ul>
            {orders.map((order: Order) => (
                <li key={order.id}>{order.id}: {order.amount}</li>
            ))}
        </ul>
    );
}
// または export const dynamic = 'force-dynamic' でページ全体をSSRにする
export const dynamic = 'force-dynamic';

export default async function OrdersPage() {
    const res    = await fetch(`${process.env.FASTAPI_URL}/orders`);
    const orders = await res.json();
    ...
}

SSG — ビルド時に生成して使い回す

// app/about/page.tsx
// ビルド時に1回だけ実行される

export default async function AboutPage() {
    const res  = await fetch(`${process.env.FASTAPI_URL}/about`, {
        cache: 'force-cache',  // SSG(デフォルト動作)
    });
    const data = await res.json();

    return <div>{data.content}</div>;
}
// 動的ルートのSSG
// app/blog/[slug]/page.tsx

// ビルド時に生成するパスを指定
export async function generateStaticParams() {
    const res   = await fetch(`${process.env.FASTAPI_URL}/posts`);
    const posts = await res.json();

    return posts.map((post: Post) => ({
        slug: post.slug,
    }));
}

export default async function BlogPost({
    params,
}: {
    params: { slug: string };
}) {
    const res  = await fetch(
        `${process.env.FASTAPI_URL}/posts/${params.slug}`,
        { cache: 'force-cache' }
    );
    const post = await res.json();

    return (
        <article>
            <h1>{post.title}</h1>
            <div>{post.body}</div>
        </article>
    );
}

generateStaticParams()はPages RouterのgetStaticPaths()に相当する。ビルド時にどのURLを生成するか指定する。

ISR — 一定時間ごとに再生成する

// app/products/page.tsx
// 60秒ごとに再生成

export default async function ProductsPage() {
    const res = await fetch(`${process.env.FASTAPI_URL}/products`, {
        next: { revalidate: 60 },  // ISR(60秒ごとに再生成)
    });
    const products = await res.json();

    return (
        <div className="grid grid-cols-3 gap-4">
            {products.map((product: Product) => (
                <ProductCard key={product.id} product={product} />
            ))}
        </div>
    );
}

CSR — ブラウザでデータを取得する

// components/RealtimeChart.tsx
'use client';

import { useState, useEffect } from 'react';

export default function RealtimeChart() {
    const [data, setData] = useState<ChartData[]>([]);

    useEffect(() => {
        // 初回取得
        fetchData();

        // 10秒ごとに更新
        const timer = setInterval(fetchData, 10000);
        return () => clearInterval(timer);
    }, []);

    async function fetchData() {
        const res  = await fetch('/api/chart-data');
        const json = await res.json();
        setData(json);
    }

    return <Chart data={data} />;
}

revalidatePathとrevalidateTag

ISRはN秒ごとに自動再生成するが、任意のタイミングで再生成したい場合はrevalidatePathrevalidateTagを使う。

revalidatePath

// Server Actionで商品を更新したらキャッシュを消す
'use server';

import { revalidatePath } from 'next/cache';

export async function updateProduct(id: number, formData: FormData) {
    await fetch(`${process.env.FASTAPI_URL}/products/${id}`, {
        method: 'PUT',
        body:   JSON.stringify(Object.fromEntries(formData)),
        headers: { 'Content-Type': 'application/json' },
    });

    revalidatePath('/products');          // 一覧ページのキャッシュを消す
    revalidatePath(`/products/${id}`);    // 詳細ページのキャッシュを消す
}

revalidateTag — より細かいキャッシュ制御

// fetchにタグをつける
const res = await fetch(`${process.env.FASTAPI_URL}/products`, {
    next: {
        revalidate: 3600,
        tags:       ['products'],  // タグをつける
    },
});
// Server Actionでタグを指定して無効化
'use server';

import { revalidateTag } from 'next/cache';

export async function updateProduct(id: number, data: ProductUpdate) {
    await fetch(`${process.env.FASTAPI_URL}/products/${id}`, {
        method:  'PUT',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(data),
    });

    revalidateTag('products');  // 'products'タグがついたキャッシュを全て無効化
}

タグを使うと「商品に関連するキャッシュを全部消す」という操作ができる。revalidatePathよりも粒度の細かい制御ができる。


戦略の使い分け基準

実際に使っていて固まってきた判断基準。

データの更新頻度と必要な鮮度で決める:

リアルタイム性が必要
  → SSR(cache: 'no-store')
  例: 在庫数、注文状況、ログイン後のパーソナライズページ

数分〜数時間で変わる
  → ISR(next: { revalidate: N })
  例: ニュース一覧、商品価格、ランキング

ほぼ変わらない
  → SSG(cache: 'force-cache')
  例: 会社情報、利用規約、ブログ記事

ユーザー操作に応じてリアルタイムで変わる
  → CSR(useEffect + fetch)
  例: リアルタイムチャート、チャット、ライブ検索

実際のページ構成例

// app/shop/page.tsx
// 商品一覧: ISR(商品情報は1時間に1回程度更新)

export default async function ShopPage() {
    const res      = await fetch(`${process.env.FASTAPI_URL}/products`, {
        next: { revalidate: 3600, tags: ['products'] },
    });
    const products = await res.json();

    return (
        <div>
            <h1>商品一覧</h1>
            {/* 商品リストはISRで取得(Server Component) */}
            <ProductList products={products} />
            {/* カートはリアルタイムが必要なのでClient Component */}
            <CartSummary />
        </div>
    );
}
// app/shop/[id]/page.tsx
// 商品詳細: ISR + Server Component + Client Component の混在

import { Suspense } from 'react';

// 商品情報をSSGで生成
export async function generateStaticParams() {
    const res      = await fetch(`${process.env.FASTAPI_URL}/products`);
    const products = await res.json();
    return products.map((p: Product) => ({ id: String(p.id) }));
}

export default async function ProductDetailPage({
    params,
}: {
    params: { id: string };
}) {
    // 商品情報はISR
    const res     = await fetch(
        `${process.env.FASTAPI_URL}/products/${params.id}`,
        { next: { revalidate: 3600, tags: ['products'] } }
    );
    const product = await res.json();

    return (
        <div>
            {/* 商品基本情報: ISR */}
            <ProductInfo product={product} />

            {/* レビュー: 別途ISR(更新頻度が違う) */}
            <Suspense fallback={<ReviewsSkeleton />}>
                <ReviewList productId={product.id} />
            </Suspense>

            {/* 在庫数: SSR(リアルタイムが必要) */}
            <Suspense fallback={<StockSkeleton />}>
                <StockStatus productId={product.id} />
            </Suspense>

            {/* カートに追加: Client Component */}
            <AddToCartButton product={product} />
        </div>
    );
}

同じページでもコンポーネントによって戦略を変えられる。Suspenseで囲むとコンポーネントのレンダリングを分割できる。


Suspenseでストリーミング

// app/dashboard/page.tsx
import { Suspense } from 'react';

// 重いコンポーネントを遅延ロードする
async function SalesChart() {
    // 3秒かかる重い処理
    const data = await fetch(`${process.env.FASTAPI_URL}/analytics/sales`, {
        cache: 'no-store',
    }).then(r => r.json());

    return <Chart data={data} />;
}

async function RecentOrders() {
    const orders = await fetch(`${process.env.FASTAPI_URL}/orders/recent`, {
        cache: 'no-store',
    }).then(r => r.json());

    return <OrderTable orders={orders} />;
}

export default function DashboardPage() {
    return (
        <div className="grid grid-cols-2 gap-6">
            {/* 重いコンポーネントをSuspenseで包む */}
            <Suspense fallback={<ChartSkeleton />}>
                <SalesChart />
            </Suspense>

            <Suspense fallback={<OrderTableSkeleton />}>
                <RecentOrders />
            </Suspense>
        </div>
    );
}

Suspenseを使うとHTMLがストリーミングで送られる。ページ全体の完成を待たずにブラウザが表示を始める。重いAPIが1つあっても他の部分はすぐ表示されるのでUXが上がる。


PHPとの比較

PHP(Laravel)のデータ取得:
  → コントローラーで全部取得してからBladeに渡す
  → ページ全体がSSR(変更できない)
  → 重い処理があるとページ全体が遅くなる

Next.js App Routerのデータ取得:
  → コンポーネント単位でデータ取得
  → SSR/SSG/ISR/CSRを混在できる
  → Suspenseで重い処理を後回しにできる

PHPだとページ全体の表示速度が「一番遅いDB/APIクエリ」に引っ張られていた。Next.jsのSuspenseを使うと重い処理を後回しにして他の部分を先に表示できる。


fetch以外のデータ取得

// DBに直接アクセス(Server Component)
import { db } from '@/lib/database';

export default async function UsersPage() {
    // fetchを使わずORM/DBを直接呼べる(Server Componentなので)
    const users = await db.query('SELECT * FROM users');
    return <UserList users={users} />;
}
// 並列でデータを取得(Promise.allで高速化)
export default async function DashboardPage() {
    // 直列に書くと合計待ち時間 = 各APIの合計
    // const users  = await fetchUsers();
    // const orders = await fetchOrders();
    // const stats  = await fetchStats();

    // 並列に実行すると合計待ち時間 = 最も遅いAPIの時間
    const [users, orders, stats] = await Promise.all([
        fetchUsers(),
        fetchOrders(),
        fetchStats(),
    ]);

    return <Dashboard users={users} orders={orders} stats={stats} />;
}

Server Componentはasync/awaitを使えるのでPromise.allで並列化できる。LaravelのEloquentのEager Loadingに近い発想で、直列取得を並列にするだけでページが速くなる。


まとめ

戦略 書き方 使い所
SSR cache: 'no-store' ログイン後ページ・リアルタイム在庫
SSG cache: 'force-cache' 静的コンテンツ・ブログ記事
ISR next: { revalidate: N } 商品一覧・ニュース・ランキング
CSR useEffect + fetch リアルタイムチャート・ライブ検索
on-demand revalidateTag() 管理画面での更新後に即反映

「どの戦略を使うか」は「データがどのくらいの頻度で変わるか」と「古いデータを表示してもいいか」で決まる。最初は全部SSRにしてしまいがちだが、静的なコンテンツはSSGかISRにするだけでレスポンスが速くなる。

1
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
1
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?