㊗️

【Next.js】app router でマルチテナント向けにサーバーサイドフェッチを実現した

2023/10/07に公開

tl;dr

クライアントサイドキャッシュを使うことでクライアント間でデータが混ざることなくサーバーサイドでフェッチを行うことができる

背景

前回の記事の悩みセクションにある通りです。
apollo client でもサーバーサイドでフェッチしたいという願いでした。

悩み

  • RSC 内でフェッチするとキャッシュがクライアント間で共有されてしまう
    -> クライアント間で共有はしたくないが、サーバーサイドフェッチしたい。かつ、どこかにデータフェッチの結果をキャッシュしたい。

解決策

  • クライアントサイドキャッシュでクライアント側でページごとのキャッシュを持つ

解説

クライアントサイドキャッシュとは

今回の肝はrouter cacheです。
(router cache はクライアントサイドキャッシュとも呼ばれます。この記事ではサーバーとの比較で分かりやすいので、クライアントサイドキャッシュと呼んでいます。)

クライアントサイドキャッシュでは、ページごとにキャッシュを持つことができます。イメージは以下のとおりです。

クライアントサイドキャッシュは RSC が返したデータをページごとにキャッシュでき、次回リクエスト時には下側のように画面のレンダリングに使うことができます。
これはセッション単位で保存されるのでリロードすると消えます。

キャッシュ期間

現在(13.5.2)は以下のように期間が決まっています。

  • 動的レンダリング(app/user/[userId]のようなパスのページ): 30 秒
  • 静的レンダリング(app/userのようなパスのページ): 5 分

勘違いしやすいですが、動的かどうかを判断するのはパスであり、実際のデータフェッチは関係ありません。
なので静的ページでもデータをフェッチしても 5 分間キャッシュされます。

現在は決められた値ですが、この話の通りいずれ staletime という route segment config
のプロパティが追加される予定です。これが追加されると自由にキャッシュ時間を決められるのでより柔軟に設計できるようになります。

キャッシュの破棄

このキャッシュは破棄する方法が用意されており、以下の方法で可能です。

  • revalidatePath: 指定したパスのページのキャッシュを破棄する
  • revalidateTag: 指定したタグをつけた fetch を使用しているページのキャッシュを破棄する
  • cookies.set,cookies.delete: cookie を使用しているページを自動で判別してキャッシュを破棄する
  • router.refresh: 現在のページのキャッシュを破棄する

このうち router.refresh 以外は server action で実行する必要があります。
今回使用するのは revalidatePath, cookies の 2 つです。

破棄する際のイメージは以下です。

cookies のキャッシュ破棄を使う場合は全てのクッキー処理を cookies で行ったほういいです。
例えば cookies.get を使用してクッキーを取得したのにサードパーティの nookies などで更新した場合、それは検知できないのでキャッシュは破棄されず以前のデータを表示してしまいます。
逆の場合も同じですね。
クライアンサイドで mutation する際にすぐ使いたい場合ならいいと思います。

cookies を使う例は以下です。

app
├── user
│  └── [userId]
│     └── page.tsx
└── else-page
   ├── UpdateUserIdComponent.tsx
   └── update-user-id.ts
app/user/[userId]/page.tsx
const Page = async () => {
  const userId = cookies.get("userId")?.value;

  return <div>{userId}</div>;
};
export default Page;
app/else-page/update-user-id.ts
"use server";

export const updateUserIdInServerAction = (id: string) =>
  cookies().update("userId", id);
app/else-page/UpdateUserIdComponent.tsx
import { updateUserIdInServerAction } from "./updateUserIdInServerAction";
export const UpdateUserIdComponent = () => {
  return (
    <button onClick={async () => await updateUserIdInServerAction("updatedId")}>
      UPDATE
    </button>
  );
};
  1. ユーザー詳細ページ(app/user/[userId]) を表示する。(cookie の userId には initialId が入っており、同じように画面にも initialId と表示されているとする)
  2. else-page で UpdateUserIdComponent のボタンをクリックして userId を更新する
  3. 再度ユーザー詳細ページを開く

こうすると、自動で updatedId に表示が変わります。(cookies を使用しているパスを自動検知してクライアンサイドキャッシュを破棄しているため、再度 RSC でレンダリングを行っている)

サーバーサイドフェッチの方法

大体の graphql クライアントでは以下に似た形でリクエストできると思います(とりあえずフック無しでフェッチできれば何でもいいです)。

client.query({
  query: GetUserByIdQuery
    variables: {
      id: "userId"
    }
})

これを使って RSC でサーバーサイドフェッチします。

app
└── user
   └── [userId]
         ├── _UserDetail
       │   ├── UserDetailContainer.tsx
       │   ├── UserDetailFetcher.tsx
       │   ├── UserDetailPresenter.tsx
       │   └── index.ts
       └── page.tsx
app/user/[userId]/_UserDetail/UserDetailFetcher.tsx
export const UserDetailFetcher = async (query) => {
  const { userId } = query;

  const { data, error } = await client.query({
    query: GetUserByIdQuery,
    variables: {
      id: userId,
    },
  });

  if (!data) return null;

  const { user } = data.get_user;

  return <UserDetailContainer user={user} />;
};

以上です。以前は Container で持っていた責務のうち、データフェッチの責務を Fetcher に持たせました。

図示すると以下のような感じですね

  1. user/[userId]にリクエストする
  2. 該当するページの Fetcher でデータをフェッチする
  3. Container にフェッチしたデータを整形して送る
  4. Container で内部のロジックで使ったり Presenter にわたすために使ったりする。

という流れです。

まとめ

全てをまとめると以下のような形です。

図を見るとクライアンサイドキャッシュ主導で動いているのがよく分かりますね。

メリット

  • サーバーサイドフェッチできる
  • 従来の Container/Presenter パターンに Fetcher を入れており自然な形で導入できる
  • swr などのタグと似たような方法でキャッシュの更新が出来るので分かりやすい。

デメリット

  • server action を使っており、まだ GA じゃないものを使用するため、すぐにプロダクションレベルで使えるわけではない。
  • apollo client の自動更新に強く依存していると移行しづらい
フィシルコム

Discussion