🦢

キャッシュで理解するNext.js App Routerのデータ取得

2023/07/13に公開1

はじめに

Next.js 13.4 から App Router が安定版になりました。
App Routerは今までのPages Routerとは大きく変わっています。
色々変化点はありますが、この記事ではデータ取得方法の変化を解説いたします。

App Routerのデータ取得は、getStaticProps などが無くなったことを筆頭に今までのインターフェイスから大きく変わっています。App Routerのデータ取得動作はキャッシュの観点から考えると説明ができます。

この記事ではまず、従来の getStaticProps などを使ったデータ取得と同様の挙動をApp Routerで再現し、キャッシュの観点で動作を解説いたします。次に、App Rotuer になって初めて可能になった細かなデータ取得の制御を紹介します。

検証環境

この記事では、実際の挙動を確認しながら進めていきます。使っているNext.jsのバージョンは 13.4.9 です。

3000 番ポートでNext.jsサーバーを動かしつつ、 3001 番ポートでもNext.jsを動かしてバックエンドAPIを再現しています。

/pages/api/now.ts
export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>
) {
  const now = (new Date()).toISOString();
  console.log("Received request: ", now);
  res.status(200).json(now);
}

/api/now にhttpリクエストが送られると、APIはリクエストを受け取った時刻をログに表示しつつ返します。

この記事の中で紹介するサンプルコードはGitHubで公開しています。
https://github.com/tasugi/nextjs-app-router-data-fetch-example

従来のデータ取得方法

まずは従来のPages Routerにおけるデータ取得と同じ挙動をApp Routerで再現するコードを紹介していきます。

getStaticProps

Pages Routerでは getStaticProps を使ってこのようなコードを書くことが可能でした。

/pages/pages/getStaticProps
import { GetStaticProps, InferGetStaticPropsType } from "next";

export const getStaticProps: GetStaticProps<{
  now: string;
}> = async () => {
  console.log("getStaticProps");
  const res = await fetch("http://localhost:3001/api/now");
  const now = await res.json();
  return { props: { now } };
};

export default function Page({
  now,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

exportされている getStaticProps 関数はビルド時に実行されます。ビルド時に得られたpropsがページリクエストの度に再利用されるので、ページをリロードしても同じ時刻が表示されます。[1]

似たような挙動(全く同じではありません。詳細は後述します。)をするコードをApp Routerで書いてみましょう。
App Routerではこのようになります。

/app/force-cache/page.tsx
/**
 * Pages RouterのgetStaticPropsに相当する
 */
export default async function Page() {
  console.log("force-cache");
  const res = await fetch("http://localhost:3001/api/now");
  const now = await res.json();
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

上に貼ったスクリーンキャプチャの通り、ページの更新をしても表示される時刻は同じです。
では、果たしてこの挙動をどう理解すれば良いのでしょうか?
答えはキャッシュの有無にあります。

実はこのようにオプションを付けずに fetch を呼び出した場合は、 Next.jsでは cache: force-cache を付けているものとして扱われます。[2]

ビルド時に Page 関数が実行されて、その内部で fetch が呼ばれ、得られた結果がキャッシュとして保存されます。そして、ページリクエストが来た時はビルド時に作られたキャッシュが再利用されています。そのため、ページをリロードしても常にビルド時の時刻が表示されています。

ここで一つ注意点を紹介しておきます。
実は保存されたキャッシュはビルドを跨いでも有効です。
Pages Routerの getStaticProps 関数はビルドの度に毎回実行されるのに対し、App Routerにおいて cache:force-cache オプション付きで取得されたデータはビルドを跨いでも有効であるため、ビルド時にデータ取得が行われない可能性がある点には注意が必要です。

それではビルド時に毎回データ取得したい場合はどうすれば良いのでしょうか?
私が調べた限りではビルドを行う前に .next ディレクトリを削除したら再度データ取得が行われる事は確認できました。ですが、もっと効率的で直感的な方法が欲しいなと思います。もし知っていれば教えて欲しいです。

getServerSideProps

続いて getServerSideProps を考えてみましょう。

/pages/pages/getServerSideProps
import { GetServerSideProps, InferGetServerSidePropsType } from "next";

export const getServerSideProps: GetServerSideProps<{
  now: string;
}> = async () => {
  console.log("getServerSideProps");
  const res = await fetch("http://localhost:3001/api/now");
  const now = await res.json();
  return { props: { now } };
};

export default function Page({
  now,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

getServerSideProps を使って書かれた上のコードと同じ挙動をするコードをApp Routerで書くと次のようになります。

/app/no-store/page.tsx
/**
 * Pages RouterのgetServerSidePropsに相当する
 */
export default async function Page() {
  console.log("no-store");
  const res = await fetch("http://localhost:3001/api/now", { cache: "no-store" });
  const now = await res.json();
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

ページを開いてみます。

リロードの度に時刻が変わりました。
Pages Routerでは getServerSideProps がページリクエストの度に実行され、都度最新の値が props として Page 関数に渡されています。
App Routerでもページリクエストの度に Page 関数が実行されます。今回は fetch のオプションで cache:no-store が指定されているためキャッシュは利用されません。そのため、ページを更新する度に最新の時刻が表示されています。

Incremental Static Regeneration

従来方式の最後として、Incremental Static Regeneration (長いので以降はISRと表記します)を考えてみます。

/pages/isr.tsx
import { GetServerSideProps, InferGetServerSidePropsType } from "next";

export const getServerSideProps: GetServerSideProps<{
  now: string;
}> = async () => {
  const res = await fetch("http://localhost:3001/api/now");
  const now = await res.json();
  return { props: { now }, revalidate: 10 };
};

export default function Page({
  now,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

getServerSideProps 関数が返すオブジェクトに revalidate:10 が含まれています。この指定により、Next.js がpropsの値を10秒間保持してくれる、というものでした。

同様の挙動をするApp Routerのコードは次のようになります。

/app/revalidate/page.tsx
/**
 * Pages RouterのIncrementalStaticRegeneration(ISR)に相当する
 */
export default async function Page() {
  const res = await fetch("http://localhost:3001/api/now", { next: { revalidate: 10 } });
  const now = await res.json();
  return (
    <>
      <p>time: {now}</p>
    </>
  );
}

今回は fetch のオプションに cache では無く revalidate が与えられています。
ページリクエストの度に Page 関数が実行されますが、その時に前回の実行で得られたデータがまだ有効期限内であれば使い、期限が切れていれば再度データを取得する、といった処理が fetch 関数の内部で行われています。

App Routerではfetchごとの細かいキャッシュ管理が可能に

これまでの部分で、従来Pages Routerのデータ取得インターフェイスをApp Routerに書き換えつつ、キャッシュの観点からApp Routerのデータ取得方式を理解してきました。

Pages Routerではデータの取得タイミングはページ単位で決まります。
ページにおいて gerStaticProps が定義されていればデータ取得はビルド時に行われ、 getServerSideProps が定義されていればリクエスト時に行われる、といった挙動になります。

一方 App Routerでは同一ページの中であっても、細かくデータの取得タイミングを制御することが可能になりました。
コード付きで実例を紹介いたします。

有効期限が異なる複数のキャッシュを持つページ

同一ページ内で複数タイミングのデータ取得が発生する事例のコードサンプルを作ってみました。

mixed/page.tsx
/**
 * 有効期限が異なる複数のキャッシュを持つページ
 */
export default async function Page() {
  const nowForceCache = await forceCache();
  const nowNoStore = await noStore();
  const nowRevalidate = await revalidate();
  return (
    <>
      <p>force-cache: {nowForceCache}</p>
      <p>no-store: {nowNoStore}</p>
      <p>revalidate: {nowRevalidate}</p>
    </>
  );
}

const forceCache = async () => {
  const res = await fetch("http://localhost:3001/api/now?key=1");
  const now = await res.json();
  return now;
}

const noStore = async () => {
  const res = await fetch("http://localhost:3001/api/now?key=2", { cache: "no-store" });
  const now = await res.json();
  return now;
}

const revalidate = async () => {
  const res = await fetch("http://localhost:3001/api/now?key=3", { next: { revalidate: 10 } });
  const now = await res.json();
  return now;
}

スクリーンキャプチャを見ていただくとわかるように、 fetch 関数に与えたオプションにより表示される時刻が異なっています。
それぞれの fetch で異なるキャッシュの有効期限を設定したため、期限に応じてデータの再取得が発生しているため表示される値が変わっていると理解できます。

このように、App Routerではページ単位ではなく、 fetch 単位でデータ取得のタイミングを変えることが可能になりました。

なお、今回のサンプルコードでは、URLのパラメータを使ってそれぞれのfetchから別のURLを呼び出しています。バックエンド側はパラメータによらず同じ挙動(時刻を返す)です。

実は全ての fetch で同じURLを呼び出すと、 forceCache を指定してもリクエスト毎に値が変わりました。これはおそらく fetch の内部でURLをキーとしたキャッシュ管理をしているため、no-storeオプションを付けて fetch を呼び出した時にURLに対応するキャッシュの値が変わったため、と推測しています。

App Routerのデータ取得はキャッシュで考える

これまで見てきたように、Pages Routerのデータ取得は

  • 同一ページの中ではデータ取得タイミングは同じ
  • ページがエクスポートする関数によってデータ取得タイミングが決まる

という特徴がありました。

App Routerでは 同一ページの中でも細かくデータ取得タイミングを変えられる という大きな進歩が達成されています。

ここまで見てきたように、App Routerにおけるデータ取得の挙動は キャッシュの有効期限 に注目すると理解しやすいでしょう。

終わりに

Next.js プロジェクトのデータ取得方法はApp Routerになり大きく変わりました。
データの取得タイミングを細かく制御できるようになったのはもちろんメリットですが、それに加えて、これまで使われていた getServerSide といった Next.js の独自APIが無くなり、 標準APIである fetch に変わったことで、これから初めて Next.js を学ぶ人にとってわかりやすくなったというメリットも有るのではないかと私は思っています。

この記事を書く前に以下の記事を読み、勉強させていただきました。ありがとうございます。

私が書いたこの記事もどなたかの参考になれば幸いです。

脚注
  1. いわゆるSSGと呼ばれる方式ですが、こちらの記事 を読み、App Router時代においてはこの呼び方はかえって混乱の元になると思うようになりました。そのためこの記事でもSSGという言い方は避けています。 ↩︎

  2. https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#static-data-fetching
    ただし、リンク先に書かれていますが、コンポーネント内部でcookiesheadersが呼ばれた時は、リクエスト情報を使っていると判断して次の節に出てくるno-cacheと同じ動きをします。 ↩︎

Discussion

yutoyuto

非常に参考になる資料でした!ありがとうございます。
一点お聞きしたいところがありまして、

getServerSideProps 関数が返すオブジェクトに revalidate:10 が含まれています。

getServerSidePropsにrevalidateを指定するのでしょうか?正しくはgetStaticPropsにrevalidateではないでしょうか?

増分静的再生 (ISR)