🧑‍🌾

サクッとわかるNext.jsのSGとISRの話

2022/01/12に公開

プロダクトLPをmicroCMSでヘッドレス化しようとした

以下の記事で以前既存のプロダクトLPをヘッドレス化する計画について書きました。
https://qiita.com/hk206/items/68c2b303d423ace22f88
今回はそのヘッドレスCMS化したLPの公開にあたり、Next.js側の設定でどのレンダリング方法を選択するのが最適なのかについての記事になります。

レンダリング方法の選定

レンダリング方法はCSR/SSR/SG/ISRの4種類がありますが、LPという特性上SEOと表示速度に焦点を当てて選定をしました。
https://nextjs.org/docs/basic-features/data-fetching

CSR

通常のSPAです。クライアント側でデータフェッチおよびレンダリングを行います。

メリット

  • リアルタイムにデータが反映がされる

デメリット

  • SEOに弱い(クローラによる)
  • データが反映されていないページが最初に一瞬表示される

結果

特に理由がない限りCSRで基本はいいのかなと思っているのですが、SEOの点で引っかかるので今回は無しとしました。

SSR

サーバーサイドでデータフェッチおよびレンダリングを行う方法です。

メリット

  • クライアント側のスペックに左右されず、高速でレンダリングできる
  • リアルタイムにデータが反映がされる
  • SEOに強い

デメリット

  • 遷移にラグを感じる(サーバーサイドでレンダリングが完了するのを待つため)

実装方法

getServerSidePropsを使って実装します。

export async function getServerSideProps(context) {
    // サーバーサイドで実施するデータフェッチなどの処理を書きます
  return {
    props: {}, // ページコンポーネントにpropsとして渡されます
  }
}

結果

SEOに強いのは嬉しいのですが、遷移のラグが気になります...。選択肢の1つとしてはありです。

SG

ビルド時にあらかじめ静的ページを生成し、リクエスト時はそのページを返すだけという方法。

メリット

  • 表示速度が速い

デメリット

  • データの更新が即時反映されない

実装方法

getStaticPropsを使って実装します。

export async function getStaticProps(context) {
  return {
    props: {}, // ページコンポーネントにpropsとして渡されます
  }
}

Dynamic Routingの場合は別途getStaticPathsを設定する必要があります。これによりどのページをビルド時に生成するか決めることができます。

// この関数はサーバーサイドのビルド時に呼び出されます
// ここで指定していないパスに対してリクエストがあった場合はSSRもしくはCSRによりページが生成されます
export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))

  // ここに指定したパスのみビルド時にプリレンダリングします
  // { fallback: blocking } と記述すると、指定していないパスに対してリクエストがあった場合SSRが実行されキャッシュが生成されます
  return { paths, fallback: 'blocking' }
}

結果

表示速度が早くSEOも強いのでできればこれが良い...!
ただデータの更新が(ビルドしないと)反映されないというのがネックだなと。完全静的ページしか使えない気がします。
定期実行でビルドさせたり、Webhookで記事更新タイミングでビルドが走るのならありですね。

おまけ
SSGって一昔前の呼び方らしいです。おじさんみ👨‍🦳を出さないためにも僕はSGって読んでます。頑張ってます。

ISR

SGの機能に加えて定期的にデータを更新して裏で新たなページを生成します。SGの欠点を補うレンダリング方法になります。

メリット

  • キャッシュにより2回目以降の表示速度が爆速

デメリット

  • 初回アクセス(つまりキャッシュがない場合)はSSR(またはCSR)になる
  • 定期的なデータ更新により新しいキャッシュ生成後の1回目のリクエストでは古いキャッシュが返される

実装方法

returnする値に、 revalidateを追加するだけです。キャッシュの再生成をする秒数になります。
getStaticPathsでpathsを指定してしまうと、そのページは初回アクセス時にビルド時の情報が表示され続けてしまうことになるので、ISR時はここを[]にしておく必要がありそうです。

export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // 10秒ごとにキャッシュを更新
    revalidate: 10, // 秒数を指定
  }
}

export async function getStaticPaths() {
  return {
    paths: [],
    fallback: 'blocking',
  };
};

結果

実装の手間もかからず、すごく便利な機能を備えています。
ただ、初回アクセス時のSSR特有の遷移ラグは気になりました。

Zennでも一部のページでISRを採用しているとのこと。
ユーザーが投稿するシステムなので、投稿頻度もかなり多いと思いますしそういった特性のものはISRのほうが良さそう。
https://zenn.dev/catnose99/articles/8bed46fb271e44

選ばれたのはSGでした

microCMSのWebhookを使用して記事更新時にビルドする

やはり何とかしてSGで実現したいと考えていたところ、microCMSのWebhookを使用する方法に行き着きました。
https://blog.microcms.io/microcms-next-jamstack-blog/
Github Actionsでビルドの定期実行をすることも考えていましたが、一番反映が早く確実なのはこの方法かなという結論に至りました。

LP内記事などはユーザーが任意で投稿するサービスとは違って投稿頻度は極めて低いと言えるので、投稿ごとにビルドするのは問題ないと考えています。
記事の一括更新などでVercelがLock-inされてしまうようなことがあれば、おとなしくISRにしようかなと。

設定方法も簡単で、Vercel, microCMSを少しいじるだけで設定が完了するのですごく楽でした。
めでたしめでたし。

選ばれたのはISRでした

2022.01.22 追記

ISRの初回アクセス時のオーバーヘッドは特段気にしなくて良い

先程はISRのデメリットとして「初回アクセス時(キャッシュがない場合)のSSRのオーバーヘッドがある」と述べましたが、キャッシュはVercelのCDNに存在するためその減少が生じるのはビルド後に初めてアクセスしたユーザー1人のみです。

なので、ビルド後に初めてアクセスしたユーザーがAさんだとすると、Aさんの初回アクセスはSSRが走るため多少のオーバーヘッドを感じます。しかしその後アクセスするBさん、CさんはVercelが提供するCDNにキャッシュがある状況でのアクセスになるため、そのままキャッシュを使用できます。

最初に記事を投稿したときには、この初回アクセスのオーバーヘッドが全ユーザーに生じるものと思い込んでいました。LPだけでなく弊社のプロダクト内でもISRを活用しようと考えていたので深堀りしていたところ、あれ?となって再調査・検証しました。

この記事をいいねくださったみなさま、申し訳ございません。精進します。

https://gihyo.jp/book/pickup/2021/0012

https://vercel.com/docs/concepts/edge-network/caching

https://vercel.com/docs/concepts/next.js/incremental-static-regeneration

その他の参考記事

https://qiita.com/thesugar/items/47ec3d243d00ddd0b4ed
https://zenn.dev/bitarts/articles/37260ddb28ae5d

Discussion