🦁

Next.js App Routerで外部ライブラリのデータフェッチをメモ化する方法

に公開

🌼 はじめに

Next.js Pages Router ではサーバー側でデータ取得したい場合 getServerSidePropsgetStaticProps などのコンポーネントの外の関数でデータを取得してクライアントに渡す方式を取っていました。

その反面、App Router からはサーバーコンポーネントを採用、データを必要とするコンポーネントでデータ取得できるようになりました。

そうすると同じデータを複数のサーバーコンポーネントで叩くことも起きますが、fetch API でデータ取得する場合は Next.js がリクエストをメモ化してくれるので、同じリクエストが何度も飛ぶ心配はありません。


https://nextjs.org/docs/app/guides/caching#request-memoization

でも実際プロジェクトでは axios や prisma など外部ライブラリを使うことがよくあり、その場合は Next.js がメモ化してくれません。なので自分でメモ化する必要があります。

それをどうメモ化するかを3つの方法で実験してみたので、共有します。

1. React cache

まず最初の方法は React が提供する cache 関数を使う方法です。

cache はデータ取得や計算の結果をキャッシュしてくれる関数で、サーバーコンポーネントでのみ使えます。そのcache を使って同じデータ取得をメモ化してみましょう。

まず外部ライブラリ(今回は axios 使用)でデータ取得する関数を cache でキャッシュします。

getPostCached.ts
export const getPostCached = cache(async (id: number) => {
  const { data } = await axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`);
  const execId = crypto.randomUUID(); // データ取得実行する度にユニークなID生成
  return { ...data, id: execId };
});

本当にキャッシュできるかどうかを目で確認するためにデータ取得が実行されるたびにユニークなIDを生成するようにしました。(キャッシュされた場合 ID が変わらないはず)

そして同じデータ取得を行うコンポーネントを3つ用意します。

ComponentA.tsx
export const ComponentA = async () => {
  const post = await getPostCached(1);
  return (
    <>
      <pre>A: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentB.tsx
export const ComponentB = async () => {
  const post = await getPostCached(1);
  return (
    <>
      <pre>B: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentC.tsx
export const ComponentC = async () => {
  const post = await getPostCached(1);
  return (
    <>
      <pre>C: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};

最後に先の3つのコンポーネントを page.tsx で呼び出します。

page.tsx
export default function Page() {
  return (
    <>
      <h2>1. react cache</h2>
      <Suspense>
        <ComponentA />
        <ComponentB />
        <ComponentC />
      </Suspense>
    </>
  );
}

ローカル起動してみると3つのコンポーネントが全部同じIDを出力している、つまりキャッシュが効いていることが確認できます。

1つ注意点は引数のデータタイプです。cache でメモ化された関数を実行するときは引数を見てキャッシュ済みかどうかを判断しますが、その時浅い比較(Shallow Comparison)します。

なので引数がプリミティブ型ではない場合、メモリアドレス同士の比較になるので別々で生成した引数を渡したらキャッシュが効かなくなります

確認のために fetchPost の引数をオブジェクトに変えてみました(コンポーネントの方も要修正)。

axiosClient.ts
// 引数をオブジェクトにする
export const fetchPost = async ({ id }: { id: number }) => {
  // ...
};

これでローカル起動してみると ID が全部違う、つまりメモ化できてないことが確認できます。

この事態を防ぐために cache でキャッシュする場合は引数をプリミティブ型にするか、オブジェクトの場合は同じオブジェクトを参照させる必要があります。

また、cache は同一処理の重複実行を避けることはできますが、リクエストを跨いでキャッシュすることはできません。つまりリロードする度に新しいリクエストは走ります。


リロードする度に ID が変わる

ですので、リクエストを跨いだキャッシュが必要な場合(キャッシュを5分間継続させたいなど)は cache は適切ではありません。

逆にリクエストを跨いだキャッシュが不要で同一リクエストの重複を除去したいだけなら cache が1番手っ取り早いと思います。

2. 'use cache'

2つ目の方法は Next.js が提供する 'use cache' ディレクティブを使う方法です。'use cache' を使うとコンポーネントや関数、ルートをキャッシュできます。

'use cache' を使うためには Cache Components 機能をオンにする必要がある[1]ので、next.config.tscacheComponents: true を追加します。

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true, // 追加
};

export default nextConfig;

それから先と同じくデータ取得する関数を作り、'use cache' を入れて関数をキャッシュします。

getPostCached.ts
export const getPostCached = async (id: number) => {
  'use cache';
  const { data } = await axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`);
  const execId = crypto.randomUUID(); // データ取得実行する度にユニークなID生成
  return { ...data, id: execId };
};

また先と同じく、同じデータ取得するコンポーネントを3つ用意します。

ComponentA.tsx
export const ComponentA = async () => {
  const post = await getPostCached(2);
  return (
    <>
      <pre>A: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentB.tsx
export const ComponentB = async () => {
  const post = await getPostCached(2);
  return (
    <>
      <pre>B: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentC.tsx
export const ComponentC = async () => {
  const post = await getPostCached(2);
  return (
    <>
      <pre>C: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};

そしてその3つのコンポーネントを page.tsx で呼び出します。

page.tsx
export default function Page() {
  return (
    <>
      <h2>2. &#39;use cache&#39;</h2>
      <Suspense>
        <ComponentA />
        <ComponentB />
        <ComponentC />
      </Suspense>
    </>
  );
}

ローカル起動してみるとキャッシュが効いていることが確認できます。

'use cache'cache と違って引数を浅い比較するのではなく、引数をシリアライズした値を比較します。

試しにデータ取得関数の引数をオブジェクトに変えてみました(コンポーネントの方も要修正)。

getPostCached.ts
// 引数をオブジェクトにする
export const getPostCached = async ({ id }: { id: number }) => {
  'use cache';
  // ...
};

このままローカル起動して確認してみると、相変わらずキャッシュが効いていることが確認できます。

ここでふと「引数をシリアライズして比較するなら、引数が {id:2,text:"A"}{text:"A",id:2} のようにプロパティの順序が違うオブジェクトの場合キャッシュ効かないのでは?」と思い、試してみたら本当に効いてなかったです。


ComponentA だけ引数オブジェクトのプロパティ順序変えてみた

プロパティ順序とかいちいち気にしてられないので、やはりプリミティブ型の引数にしたほうがいい気がします。

また、'use cache' はリクエストを跨いでキャッシュすることができます。'use cache' のキャッシュのデフォルト寿命は15分なので、その間は何回リロードしてもずっとキャッシュが使われます。(キャッシュ寿命は cacheLife で調整できる)


リロードしても ID が変わらない

同一リクエスト内の重複を避けつつ、リクエストをまたいで結果も使い回したいなら 'use cache' がいいでしょう。ただし Cache Components 機能をオンにすると Suspense 境界が必須になりやすいなど、Cache Components 機能のルールが適用されるのでその理解が必要になります。

なお Cache Components は Next.js v15 までは experimental でしたが、v16(2025/10/21)から正式機能になりました。バージョンアップ頑張りましょう。

3. Route Handlers + fetch

最後は Route Handlers で Next.js で API を作り、その API を fetch で叩く方法です。この方法だと「サーバーコンポーネント」→「Route Handlers」→「外部API」という流れになり、サーバーコンポーネントで呼ぶ fetch('/api/…') は Next.js のリクエストメモ化が動くため、同一リクエストの重複は除去されます。

実験のために app/api 配下で API を実装します。

app/api/posts/[id]/route.ts
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const postId = parseInt(id, 10);

  const { data } = await axios.get(`https://jsonplaceholder.typicode.com/posts/${postId}`);
  const execId = crypto.randomUUID(); // データ取得実行する度にユニークなID生成

  return NextResponse.json({ ...data, id: execId });
}

そして今まで通り同じデータ取得するコンポーネントを3つ用意します。

ComponentA.tsx
export const ComponentA = async () => {
  const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${3}`);
  const post = await response.json();
  return (
    <>
      <pre>A: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentB.tsx
export const ComponentB = async () => {
  const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${3}`);
  const post = await response.json();
  return (
    <>
      <pre>B: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};
ComponentC.tsx
export const ComponentC = async () => {
  const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${3}`);
  const post = await response.json();
  return (
    <>
      <pre>C: {post.title}</pre>
      <div>ID: {post.id}</div>
    </>
  );
};

その3つのコンポーネントを page.tsx で呼び出します。

page.tsx
export default function Page() {
  return (
    <>
      <h2>3. Route Handlers + fetch</h2>
      <Suspense>
        <ComponentA />
        <ComponentB />
        <ComponentC />
      </Suspense>
    </>
  );
}

ローカル起動してみるとキャッシュが効いていることが確認できます。

Route Handlers + fetch もリクエストを跨いでキャッシュできます。revalidate オプションでキャッシュ寿命設定できるので、ComponentA だけキャッシュの寿命を30秒に設定してみました。

ComponentA.tsx
export const ComponentA = async () => {
  const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${3}`, {
    next: { revalidate: 30 }, // 寿命を30秒に設定
  });
  const post = await response.json();
  return (
    <>
      <pre>A: {post.title}</pre>
      <div>ID: {post.id} (30秒間キャッシュ)</div>
    </>
  );
};

これで確認してみると、ComponentA だけリロードしてもキャッシュが効いていることが確認できます。


ComponentA だけリロードしても ID が変わらない

Route Handlers + fetch も同一リクエストの重複除去 + リクエストを跨いだキャッシュができるし、API をもう一つ実装することになるので HTTP レイヤで共通処理が必要な場合は適切だと思います。ただ API がもう1つ増える分遅延が発生する可能性があります。

🌷 終わり

やはり設計はトレードオフということを考える記事でした。

この記事で使ったコードは github にあげてます(実験用なのでディレクトリ構造とか結構適当です)。もし気になる方は直接触ってみてもいいかもしれません。
https://github.com/gardensky511/nextjs-cache-lab

脚注
  1. cacheComponents: true にすると、レンダー中に未キャッシュのデータ取得で待ちが発生し得るコンポーネントは上位に Suspense が必要になります。https://nextjs.org/docs/app/getting-started/cache-components?utm_source=chatgpt.com#missing-suspense-boundaries ↩︎

GitHubで編集を提案

Discussion