Next.jsのいろいろなレンダリング方法を確認する

2023.07.20

Introduction

最近Next.jsを少しさわっています。
私がフロント系のフレーワークにさわったのは
数年前にAngular2.0を使ったのが最後でした。

実際にNextをさわり始めたところ、Next.jsは
レンダリング方法がいろいろあって
少々混乱したので整理します。

Next.js?

Next.jsの基本については、ここに公式チュートリアルの
まとめがあるので基本をおさえておきましょう。  

Next.jsは基本的にはフロントエンドのフレーワークです。
(Angularはフルスタックのフレーワーク)
小規模なアプリであればAPIルート機能をつかって
サーバサイドのエンドポイントを作成し、
ビジネスロジックやDBアクセスなどをさせることも可能です。
しかし通常はバックエンド用のサーバを用意し、
APIで通信を行うというのが推奨されてます。

Next.jsのレンダリング

Next.jsではいろいろなレンダリング方法を選択できます。
サーバサイド・クライアントサイドどちらでレンダリングするか、
どのタイミングでレンダリングするかなど、
それぞれメリット・デメリットがあるので確認してみましょう。

Environment

  • Node : v18.15.0
  • Next : 13.4.8
  • React : 18.2.0

Nextは13以前と以降でけっこう違う部分が多いみたいなので注意。

Setup

Next.jsテンプレートからプロジェクトを作成します。
下記コマンドを実行し、その後の質問にすべてデフォルトで答えます。
(なのでApp Routerを使う)

% npx create-next-app next-example

run devコマンドを実行するとサーバが起動します。
localhost:3000にアクセスしてNext.jsの
デフォルトページが表示されればOKです。

% cd next-example
% npm run dev

Check Rendering method

Next.jsで選択可能なレンダリング方法には以下のものがあります。

  • CSR (Client-Side Rendering)
  • SSR(Server-Side Rendering)
  • SSG(Static Site Generation)
  • ISR (Incremental Static Regeneration)
  • On Demand ISR(On Demand Incremental Static Regeneration)

まずはCSRから順番に見ていきましょう。

CSR (Client-Side Rendering)  

ブラウザ上でJavaScriptを使用してコンテンツを生成する方法。
空のHTMLを返し、その後JavaScriptを実行してコンテンツが
レンダリングされます。 後述するSSRと比較すると、
最初の表示に時間がかかる可能性がありますが、
UXの向上や動的なコンテンツの処理にマッチしています。
また、SSG(後述)と比べて、ビルド時にHTMLを生成するのではなく、
クライアント側で動的にレンダリングするため、
リクエストごとにコンテンツが生成されます。

ではCSRのサンプルを作成してみましょう。
さきほど作成したプロジェクトのappディレクトリに
csrディレクトリを作成し、その下にpage.tsxファイルを作成します。

"use client"; 
import { useState, useEffect } from 'react';

interface Todo {
    userId: number;
    id: number;
    title: string;
  }

const CSRendering = () => {
  const [data, setData] = useState<Todo | null>(null);

  useEffect(() => {
    fetchData();
  }, []);

  const fetchData = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    const data:Todo = await response.json();
    setData(data);
  };

  return (
    <div>
      <h1>Client Side Rendering Example</h1>
        <div>
          <p>Id: {data?.id}</p>
          <p>Title: {data?.title}</p>
          <p>UserId: {data?.userId}</p>
        </div>
    </div>
  );
};

export default CSRendering;

App Routerのコンポーネントはデフォルトで
Server Component として扱われます。
Client Component として扱いたい場合には
"use client";ディレクティブを
ファイルの先頭で宣言する必要があります。

ここでServer ComponentとClient Componentの
確認をしておきます。

Server Components

これはサーバーサイドでのレンダリングを行うコンポーネント。
ロジックとレンダリングがサーバサイドで実行されて
HTMLがクライアントに返される。
そのため、バックエンドのビジネスロジック、DBアクセス、
外部API実行などが可能。
appディレクトリ内の全てのコンポーネントはデフォルトで
Server Componentです。

Client Components

クライアント上でレンダリングされるコンポーネント。
ロジックとレンダリングはブラウザ上で実行されます。
主に双方向UIと動的な更新、ユーザーイベントのハンドリングを行います。
また、クライアントからサーバへデータを送信する処理も担当します。

コンポーネントが useState/useEffectなどを使用する場合のみ、
ファイルの先頭にト'use client'ディレクティブを記述し、
コンポーネンをクライアントコンポーネントとして明示します。
ディレクティブを記述しない場合はServer Componentsとして
レンダリングされるようにします。

localhost:3000/csrにアクセスしてみると、
最初の表示が完了するまで少しだけ
時間がかかるかもしれません。

SSR (Server-Side Rendering)

エンドポイントにアクセスがあるたびに、
サーバがデータを取得&サーバ側でレンダリングして
クライアントへHTMLを返します。この手法だとSEOにも有効です。
CSRの処理を一部分切り出して、
サーバサイドで動的に動かすイメージです。
CSRと比較して、メリットは初回表示までの時間が短くなることで、
デメリットはアクセスごとにサーバ側でデータ取得&ページ生成処理
処理が実行されることです。
後述するSSGと比較すると、アクセスごとにサーバサイドで
コンテンツが生成されるため、リアルタイムデータが必要だったり
個別のリクエストに応じた情報がほしいケースに適しています。

SSRのサンプルです。
app/ssrディレクトリを作成し、その下に
page.tsxを作成して下記内容を記述します。

interface SsrTodo {
    userId: number;
    id: number;
    title: string;
    completed: boolean;
}

export default async function SsrPage() {
    const todo:SsrTodo = await getData();

    console.log(todo);
    return (
      <ul>
          <li> Id : {todo.id}</li>
          <li> userId : {todo.userId}</li>
          <li> title : {todo.title}</li>
          <li> completed : {todo.completed.toString()}</li>
      </ul>
    );
  }

async function getData() {
    const res = await fetch('https://jsonplaceholder.typicode.com/todos/1');
    return res.json();
}

localhost:3000/ssrにアクセスするとサーバサイドでレンダリングされた
データが返されます。

SSG(Static Site Generation)

このレンダリング方法は、ビルド時に静的なHTMLを生成する機能です。
事前に完成した静的なコンテンツ(HTML)を
生成してしまおうというのがSSGの目的です。
ビルド時に必要なデータを取得し、
生成されたファイルをCDNなどにキャッシュすることにより、
高速な配信を可能にします。

SSGは静的なウェブサイトやブログなど、更新が頻繁でないコンテンツを
持つアプリに適しています。

ではSSGのサンプルを動かしてみます。
以前はnext exportコマンドで静的ファイルを生成していたようですが、
Next13の場合は設定ファイルとbuildコマンドで生成できる様子。

まずはnext.config.tsを修正。

module.exports = {
    experimental: {
      appDir: true,
    },
    output: "export",
};

そしてapp/ssg/[id]ディレクトリを作成し、page.tsxを下記内容で記述。

export async function generateStaticParams() {
  const todos = await fetch('https://jsonplaceholder.typicode.com/todos/').then((res) => res.json())

  //5件だけ取得
  const m = todos.slice(0,5).map((todo:any) => ({
    id: String(todo.id)
  }));
  return m;
}

export default async function Post({params: { id},}: {params: { id: number,};}) {
  const todo = await fetch("https://jsonplaceholder.typicode.com/todos/" + id).then((res) => res.json());
  const post = JSON.parse('{"id":' + id + ',"body":"'+ todo.title +'"}');
  return <pre>{JSON.stringify(post)}</pre>;
}

SSGの手順もv13でけっこうかわっているみたいで、
昔の情報がでてきたりしてけっこうハマった。

そしてbuildコマンドを実行。

% npm run build

> next-example@0.1.0 build
> next build

2023-07-19T15:02:12.895072+09:00
- info Generating static pages (13/13)
- info Finalizing page optimization

Route (app)                                Size     First Load JS
┌ ○ /                                      314 B          84.4 kB
├ ○ /csr                                   921 B          78.7 kB
├ ● /ssg/[id]                              148 B          77.9 kB
├   ├ /ssg/1
├   ├ /ssg/2
├   ├ /ssg/3
├   └ [+2 more paths]
+ First Load JS shared by all              77.7 kB
  ├ chunks/769-723f5059d3af29e9.js         25.2 kB
  ├ chunks/bce60fc1-85653116803f8735.js    50.5 kB

・・・

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

outディレクトリが生成され、out/ssg/1.htmlとかができてます。
serveで確認してみましょう。

% npx serve ./out

   ┌──────────────────────────────────────────┐
   │                                          │
   │   Serving!                               │
   │                                          │
   │   - Local:    http://localhost:3000      │
   │   - Network:  http://192.168.11.4:3000   │
   │                                          │
   │   Copied local address to clipboard!     │
   │                                          │
   └──────────────────────────────────────────┘

localhost:3000/ssg/1.htmlとかにアクセスすると、画面が表示されます。
SSGについてはこの記事でも扱ってるので、あわせてご確認ください。

ISR(Incremental Static Regeneration)

ISRは静的に生成したページをrevalidateで指定した
時間を超えてからアクセスがあった時、
更新がないかチェックしてからページ更新する機能です。

さきほどのSSG方式だと、
ページが更新されるたびにすべてビルドしていたら高コストになります。
こういったケースではISRを使用することで、
効率よくページ生成が可能です。
ISRは「頻繁に変更されるがリアルタイムの更新を必要としない」
コンテンツに有用とのこと。
Next.js13ではfetch() 時にrevalidateを指定すればOKです。

interface IsrTodo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

export default async function IsrPage() {
  const todo:IsrTodo = await getData();
  return (
    <ul>
        <li> Id : {todo.id}</li>
        <li> userId : {todo.userId}</li>
        <li> title : {todo.title}</li>
        <li> completed : {todo.completed.toString()}</li>
    </ul>
  );
}

async function getData() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos/10',{
    next: { revalidate: 3 }
  });
  return res.json();
}

On-demand ISR

このレンダリング手法は、ISRに柔軟性をもたせたような機能です。
ISRの場合、↑でやっていたようにrevalidateで指定した時間がたたないと
ページ更新されませんが、
この手法だと任意のタイミングで更新が可能です。
例えばHeadless CMSのコンテンツ更新時やショッピングサイトの
メタデータが変更されたときなどに有用です。

具体的に、ページ更新ではrevalidatePath・revalidateTagという
機能を使うことで対応できます。

ではここを参考に実装してみます。

まずはrevalidatePathを使ってみましょう。
app/hogeディレクトリにpage.tsxファイルを作成します。

interface HogeDate {
    datetime: string;
    timezone: string;
  }

  export default async function Hoge() {
    const date:HogeDate = await getData();
    return (
      <ul>
          <li> datetime : {date.datetime}</li>
          <li> timezone : {date.timezone}</li>
      </ul>
    );
  }

  async function getData() {
    const res = await fetch('http://worldtimeapi.org/api/timezone/Asia/Tokyo');
    return res.json();
  }

/hogeにアクセスすると、現在時刻を返します。
次にapp/validate-path/[path]/page.tsxを作成し、
更新処理を定義します。

import { revalidatePath } from "next/cache";

export default async function validate({params: {path},}: {params: { path: string,};}) {

  //pathを指定して更新
  revalidatePath( `/${path}`);

  const now = new Date();
  return (
    <>{now.toString()}</>
  );

}

/hogeに何度がアクセスしてみます。
リロードしても時間表示がかわりませんが、
/validate-path/hogeにアクセスしてから
再度/hogeにアクセスすると、表示が更新されます。
hogeと同じ内容のページをfugaディレクトリに
作成して確認してみると、
指定したpathのページだけが更新されるのがわかります。

次はrevalidateTagを確認してみましょう。
これはfetch実行時にtagsを指定して、
それに対応したページを更新する機能です。

app/hoge/page.tsxファイルを修正します。

interface HogeDate {
    datetime: string;
    timezone: string;
  }

  export default async function Hoge() {
    const date:HogeDate = await getData();
    return (
      <ul>
          <li> datetime : {date.datetime}</li>
          <li> timezone : {date.timezone}</li>
      </ul>
    );
  }

  async function getData() {
    const res = await fetch('http://worldtimeapi.org/api/timezone/Asia/Tokyo',{
        next: { tags: ["hoge-page"] }
    });
    return res.json();
  }

ここによると、呼び出し箇所に応じて設定する値を指定するとのこと。
ここではtagsに「hoge-page」という値を設定しています。

そしてvalidate-tag/[tag]/page.tsxを作成します。
ここでは指定したtagをrevalidateTagに渡すだけです。

import { revalidateTag } from "next/cache";

export default async function Post({params: {tag},}: {params: { tag: string,};}) {
  console.log(tag);
  revalidateTag(tag);

  const now = new Date();
  return (
    <>{now.toString()}</>
  );
}

さきほどと同じく/hogeにアクセス後、validate-tag/hogeにアクセスして
再度/hogeにアクセスすれば更新されていることがわかります。
tag方式であれば、複数の任意のコンテンツをグループ化して
更新することができます。

Summary

今回はNext.jsのいろいろなレンダリング方法について確認してみました。
ケースに応じて的確にレンダリング方法をつかいわけましょう。

Appendix

今回サンプルを作成していた際、
Route Handlerをつかったら↓みたいなエラーがでた。

- error StaticGenBailoutError: Page with `dynamic = "error"` couldn't be rendered statically because it used `request.url`.
・
・
・
next/dist/server/response-cache/index.js:99:36 {
  code: 'NEXT_STATIC_GEN_BAILOUT'

Root handlerに↓のコードいれたらエラーはでなくなったが、
今度はNextRequestのnextUrl.searchParams.get("path")とかで
クエリパラメータがとれなくなった。

export const dynamic = "force-static";

結局next.config.jsの「output: "export"」を消したら
問題なく動いた。
これがバグなのか想定の動きなのか不明だけど、注意しましょう。

References