🦊

Next.jsのISRで動的コンテンツをキャッシュするときの戦略

2021/03/17に公開
8

最近Next.jsのISR(Incremental Static Regeneration)を耳にする機会が増えてきました。Zennでも2021/3/17時点で記事や本などの一部のページでISRを採用しています。

ISRとは何か

ISRを使うことで、動的なコンテンツを含むページも静的ページとしてCDNにキャッシュすることが可能になります。Next.jsのISRはドキュメントに書かれているようにstale-while-revalidateという考え方でキャッシュが行われます。

具体的には、リクエスト時にページのキャッシュを作成し、次のアクセスではキャッシュされた古いデータを返します。その裏で次のアクセスに向けてキャッシュが再生成されるというイメージです。

これによりユーザー投稿コンテンツであってもCDNにキャッシュしやすくなるというわけです。

Next.jsでのISRの実装

デプロイ先がVercelなどのISRに対応したサーバーであれば、ISRの導入はとても簡単です。ISRをしたいページのコンポーネントでgetStaticPropsの返り値にrevalidateを含めるだけです。

pages/posts.tsx
import { NextPage, GetStaticProps } from 'next';

type Props = {
  posts: Post[]; // 詳細は省略
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const posts = await getPosts();
  return {
    props: {
      posts,
    },
    revalidate: 10 // 👈 ポイント
  };
};

const Page: NextPage<Props> = (props) => {
  return (
    <ul>
      {posts.map((post) => (
        <li>{post.title}</li>
      ))}
    </ul>
  );
};

export default Page;

getStaticProps単体だと長期間キャッシュされる静的なページが出力されます(いわゆるSSGというやつ)。ここにrevalidateを追加するとISRになります。例えばrevalidate: 10とすると以下のような挙動になります。

  • キャッシュが作られた後、10秒間はそのキャッシュを返し続ける(10秒以内に100回アクセスされてもキャッシュの再生成はされない)
  • 10秒経ったあとはキャッシュが古くなったとみなされる。ただし次のリクエストでも一旦はそのキャッシュを返す(1時間後にアクセスがあった場合も一旦古いキャッシュを返す)
    • その裏でキャッシュを再生成する
      • その次のリクエストでは再生成されたキャッシュを返す

Vercelにデプロイした場合、ISRでキャッシュされたページのレスポンスヘッダにはx-vercel-cache: HITが含まれます。


なお、posts/[id]のようにURLに含まれるパラメーターごとにページの内容が変わるような場合にはgetStaticPathsも合わせて指定する必要があります。

pages/posts/[id].tsx
 export const getStaticProps: GetStaticProps<Props> = async () => {
   const posts = await getPosts();
   return {
     props: {
       posts,
     },
     revalidate: 10
   };
 };
 
+ export const getStaticPaths: GetStaticPaths = async () => {
+   return {
+     paths: [], // アプリのビルド時にはパスに何が入るかが分からないので空でOK
+     fallback: 'blocking', // 👈 ポイント
+   };
+ };

 export default (props: Props) => {
   return (
     <ul>
       {posts.map((post) => (
         <li>{post.title}</li>
       ))}
     </ul>
   );
 };

fallback: 'blocking'は、ざっくりというと「キャッシュがまだ作られていないときはSSRを行う」という指定になります。指定しなかった場合、初回リクエスト(キャッシュ未生成時)にはSPAのような動きになります。TwitterBotなどのクローラーが動的に生成されたコンテンツの中身を読めるようにするためにfallback: 'blocking'を指定しておいた方が良いと思います。

ISR採用時にどのように最新のデータを表示するか

ユーザー投稿機能のあるサービスでISRを採用した場合、ユーザーが内容を更新した直後にページにアクセスするとキャッシュされた古い内容が表示されてしまうと問題が発生します。ユーザーが何回かリロードしてようやく最新の内容が表示される…みたいなことが起きてしまいます。

動的にキャッシュは削除できないの?

「内容が更新されたときにキャッシュを削除できるようなAPIとかはないの?」と思うかもしれませんが、少なくとも今はありません。

そもそもそれが可能ならISRをするメリットはありません。普通に長時間のキャッシュを作り、内容が更新された瞬間にキャッシュを削除した方が効率的です(ちなみにCDNにFastlyを使えば、インスタント・パージによりこれが実現可能です)。

強整合性が求められるページにISRは向いていない

常に最新のデータが表示されなければならないページではISRをするべきではありません。getServerSidePropsgetInitialPropsを使ってSSR(サーバーサイドレンダリング)をするか、SPAでやるような形でクライアントにマウントされてからリクエストを送るのが良いでしょう。

変更の反映が多少遅れても構わないようなページがISRを採用すべき対象になります。

唯一の対処法: クライアントでマウント後に最新のデータをフェッチ

以前Vercelの中の人もツイートしていましたが、ISRをしながら最新のデータを表示する唯一の方法は、ブラウザにマウントされてから最新のデータを読み込み直すことです。

https://twitter.com/chibicode/status/1299500165418479616

つまり、一瞬古いキャッシュが表示されるが、その後最新のデータに書き換えるというわけです。内容が書き換わることによるちらつきが発生しますが、初回の表示までは高速です。

Zennの投稿ページの例

よく聞かれるのですが、Zennでは投稿ページにおいてISRを採用しています。単純にISRだけをやるとユーザーが内容を編集した直後に投稿ページにアクセスすると古い内容が表示されてしまいます。一方で、ブラウザに読み込まれてから毎回APIサーバに最新のデータをリクエストすると負荷が大きくなります。

そこでZennでは著者本人の場合のみ最新のデータをリクエストするようにしています。具体的には以下のような実装をしています(わかりやすさのために一部を抜粋)。

/pages/articles/[slug].tsx
import { NextPage, GetStaticPaths, GetStaticProps } from 'next';


type Props = {
  slug: string;
  article: Article;
}

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const slug = params.slug;
  const { article } = await getArticle({ slug }); // 記事のデータをAPIから取得
  return {
    props: {
      slug,
      article
    },
    revalidate: 3,
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    paths: [],
    fallback: 'blocking',
  };
};

const Page: NextPage<Props> = (props) => {
  const [article, setArticle] = useState<Article>(props.article)
  const { currentUser } = useCurrentUser(); // 認証周りのカスタムフック
  const isMine = currentUser && currentUser.id === props.article.user.id;

  // 著者本人なら改めてフェッチ
  useEffect(() => {
    if (isMine === false) return;
    (async function () {
      try {
        const data = await getArticle({
	  slug: props.slug
	});
        setArticle(data.article);
      } catch (err) {
        // エラーハンドリング
      }
    })();
  }, [isMine])

  return <div>{article.title}</div>

}

export default Page;

↑ ログインユーザーと著者が一致している場合はクライアントから最新のデータを取得して表示するようにしています。APIリクエストについてはNext.jsと同じくVercelチームによりメンテナンスされているSWRを使うとよりスッキリと書けると思います。

↓ SWRとgetStaticPropsとの併用についてはドキュメントでも説明されています。

https://swr.vercel.app/docs/with-nextjs#pre-rendering

ここではかなり簡略化して載せましたが実際はもう少し複雑になります。特に記事の削除・非公開時の見せ方は難しいポイントだったりします(getStaticPropsでエラーを出してもキャッシュが上書きされないので、仕方なく404ページもキャッシュ対象にしている等)。

動的なコンテンツをキャッシュするわけなので辛い部分が出てくるのはまあ当然ですね。他にも「このへんが辛いよISR」や「こうやるといいよ」などの知見があればコメントで教えていただけると嬉しいです。

Discussion

grassedgegrassedge

わかりやすい記事ありがとうございます。

途中で少し書かれている「CDNとの併用」について気になっていて、ご質問させてください。
CDNが利用できるという前提の場合、ISRのCDNに対する優位性はどういったものになるでしょうか。

正直ISRについての説明をみるたび、CDN使った方がいいのではないかと感じています。
本文中に書かれているようにCDNであればインスタントパージができる製品がありますし、キャッシュの柔軟性についてもCDNに一日の長があるように思います。

zenn で CDN によるキャッシュではなく ISR を選択した理由など、お聞かせいただけないでしょうか。

catnosecatnose

フロントエンドとバックエンド(API)が分かれているアプリの場合、CDNのインスタントパージ機能を使うとロジックが複雑になります。

前提として、主にキャッシュしたいのはAPIレスポンスではなく、フロントエンドのページファイルそのものです。

例えばhttps://zenn.dev/foo/barというページの内容が更新されたとします。インスタントパージはデータの更新と合わせてバックエンドから行うのが自然だと思います。
しかしパージの対象はフロントエンドのURLです。つまり、バックエンドがフロントエンドについてよく知っている必要が出てきます(このデータが更新されたときにキャッシュをパージしなければならないURLはどれか)。

そんなわけでフロントエンドとバックエンドが分かれているような構成ではISRは重宝します。また、Next.js + Vercelという構成に限っていえば、CDNを自分で準備しなくても動的コンテンツをキャッシュできるというのが大きいと思います。

にゃんこにゃんこ

大変参考になりました、ありがとうございます。
記事末で言及されておりますが、公開→削除/非公開にステータスが変更されたような即時性が求められるシチュエーションの為に404エラーページもキャッシュされているとの事ですが、結局のところ該当記事にアクセスがある度にその記事が公開ステータスなのか、削除/非公開ステータスなのかを判定してページ内容の出し分けをする必要があるのではないかと思うのですが、その辺りはどのようなアプローチをされておりますでしょうか。
それとも404ページも投稿者にのみ向けたもので、その他の訪問者にはISRのキャッシュ更新後に反映されているのでしょうか。お教え頂けますと幸いです。

catnosecatnose

既にZennでは数ヶ月前にISRをやめているので記憶があいまいなのですが、以下のような形だったと思います。

記事が公開から非公開に変更された直後は、キャッシュが更新されるまで404になりません。ただし非公開に変更した後にはまずはじめに著者がアクセスする可能性が高いため、著者にとってもその他のユーザーにとっても最新の情報が表示されることが期待できると思います。
キャッシュされたページが404などの場合は、その他の訪問者によるアクセスの場合にも再フェッチを行うようにしていました。

どぎーどぎー

最近 Web フロントエンドを学び始めた者です。

勘違いであれば指摘していただきたいのですが、以下の部分の getStaticPropsgetServerSideProps のことでしょうか…!

getStaticPropsやgetInitialPropsを使ってSSR(サーバーサイドレンダリング)をするか、SPAでやるような形でクライアントにマウントされてからリクエストを送るのが良いでしょう。

SSG だとサイトのビルド時にキャッシュが生成され、ビルドし直さない限りはそれ以降キャッシュが変更されないので、強整合性が求められるページには SSG も ISR も使用しない認識でした…!

catnosecatnose

ご指摘ありがとうございます!getServerSidePropsとの書き間違えでした。修理しました🙏

morishinmorishin

動的にキャッシュは削除できないの?

執筆された時は無かったかもしれませんが、今は "On-Demand Revalidation" があります! https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#on-demand-revalidation

以下雑談ですが

そもそもそれが可能ならISRをするメリットはありません。普通に長時間のキャッシュを作り、内容が更新された瞬間にキャッシュを削除した方が効率的です

はその通りだと思いつつ、強整合性を求めなければ On-Demand Revalidation を使うと Vercel 単体でキャッシュのような仕組みを提供できる (Fastly などを利用する必要がない) のでお手軽でいいかもと思っています 💭