🏷️

Supabaseからのデータ取得をNext.jsのrevalidatePath/revalidateTagを使用してキャッシュ制御する

2023/08/07に公開
1

背景

Supabase上のデータをNext.js(App Router)で表示するサイトを作成しています。

ただし、データは頻繁に更新されないのでページにアクセスする度にデータを都度取得するのではなく、データが更新されたタイミングでページをビルドし直すようにしたいと考えました。

調べたところ、Next.js v13.4で追加されたrevalidatePath/revalidateTagが利用できそうでしたが、Supabaseと組み合わせる際に躓いたので記事として残しておきます。

Supabase

SupabaseはpostgreSQLベースのオープンソースのBaaSです。
https://supabase.com/

revalidatePath/revalidateTag

revalidatePath/revalidateTagはNext.js v13.4で追加されたキャッシュ制御方法です。

revalidatePathはpathに関連付けられたデータを、revalidateTagはtagに関連付けられたキャッシュデータを再検証時間の終了を待たずに更新することができます。
https://nextjs.org/docs/app/api-reference/functions/revalidatePath
https://nextjs.org/docs/app/api-reference/functions/revalidateTag

失敗例

下記のnot-workingブランチとしてもまとめています。
https://github.com/nih4shi/supabase-revalidate

はじめに、Supabaseからデータを取得する処理を書きます。

そのままだとfetchで書くことができない?ので、一旦Route Handlersを使用して記述した後、それをfetchで呼ぶような記述にしてみます。もう既に実装に違和感がありますね...

また、キャッシュが更新されているかを簡単に確認できるようにレスポンスにはDate.now()を含めておきます。

src/libs/supabase-client.ts
import { createClient } from "@supabase/supabase-js";

const supabaseUrl: string = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const supabaseAnonKey: string = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
src/app/api/supabase/name/route.ts
import { NextRequest, NextResponse } from "next/server";
import { supabase } from "src/libs/supabase-client";

export async function GET(request: NextRequest) {
  const { data, error } = await supabase.from("name").select("*");

  if (!data) return [];

  return NextResponse.json({ data: data, now: Date.now() });
}

記述したAPIをfetchで呼びます。今回はrevalidateTagを使用します。

src/app/api/supabase/name/fetch.ts
import { apiUrl } from "@/const/apiUrl";

export const fetchSupabaseName = async () => {
  const fetchResponse = await fetch(`${apiUrl()}/supabase/name`, {
    next: { tags: ["supabase-name"] }, // revalidateTag
  });

  if (fetchResponse.status !== 200) {
    return { data: [], date: Date.now() };
  }

  const { data, now } = await fetchResponse.json();

  return { data: data, date: now };
};

pages.tsxには最低限の内容を書きます。

src/app/page.tsx
import { fetchSupabaseName } from "./api/supabase/name/fetch";

export default async function Home() {
  const { data, date } = await fetchSupabaseName();

  return (
    <main>
      <div>
        <div>date: {date}</div>
      </div>
    </main>
  );
}

最後に、revalidateTagを更新するためのメソッドを書きます。

src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const tag = request.nextUrl.searchParams.get("tag") || "/";
  revalidateTag(tag);
  return NextResponse.json({ revalidated: true, now: Date.now() });
}

実装が正しければ、これで

  • ページに何度アクセスしても、Date.now()の内容は変化しない
    • Next.jsのfetchのデフォルト動作はforce-cacheであるため
  • GET /api/revalidate?tag=supabase-nameを行うとキャッシュデータが更新され、Date.now()の内容が変化する

のような挙動になるはずです。

しかし、実際にはこの実装だとGET /api/revalidate?tag=supabase-nameを行ってもキャッシュデータの更新は行われませんでした。

また、ビルドした際のレンダリングがSSRになっているのも気になります。(Staticになる想定だった。Route Handlersを使用してもSSRになる?)

Route (app)                                Size     First Load
┌ λ /                                      137 B          78.2
├ λ /api/revalidate                        0 B
├ ○ /api/supabase/name                     0 B
└ ○ /favicon.ico                           0 B

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

調査

原因・解決方法(というより正しい実装方法)について調べていたところ、下記のTweet・issueにたどり着きました。
https://twitter.com/codewithbhargav/status/1633759016710176769?s=20

https://github.com/supabase/supabase-js/issues/438

結論としては、SupabaseのCutom fetchを変更する方法を使用するとのことです(デフォルトはcross-fetch)。

https://supabase.com/docs/reference/javascript/initializing

supabase-js uses the cross-fetch library to make HTTP requests,
but an alternative fetch implementation can be provided as an option.
This is most useful in environments where cross-fetch is not compatible (for instance Cloudflare Workers).

成功例

前述のissueを参考に、コードを修正します。

下記のmasterブランチとしてもまとめています。
https://github.com/nih4shi/supabase-revalidate

はじめにCustom fetchの設定を行います。

その際、引数としてrevalidateTagsを受け取れるようにしておきます。

src/libs/supabase-client.ts
import { createClient } from "@supabase/supabase-js";
import { SupabaseClientOptions } from "@supabase/supabase-js/dist/module/lib/types";

const supabaseUrl: string = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const supabaseAnonKey: string = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";

export const getSupabase = (tags: string[]) =>
  createClient(supabaseUrl, supabaseAnonKey, {
    fetch: fetch(supabaseUrl, { next: { tags } }),
  } as SupabaseClientOptions<"public">);

fetch.tsは失敗例のようにRoute Handlersをfetchで呼ぶのではなく、getSupabaseを使用してSupabaseの処理を直接記述するようにします(src/app/api/supabase/name/route.tsは削除)。なんとなく感じていた失敗例の実装の違和感も払拭されました。

src/app/api/supabase/name/fetch.ts
import { getSupabase } from "@/libs/supabase-client";

export const fetchSupabaseName = async () => {
  const { data, error } = await getSupabase(
    ["supabase-name"] // revalidateTag
  )
    .from("score")
    .select("*");

  if (!data) return { data: [], date: Date.now() };

  return { data: data, date: Date.now() };
};

この実装でGET /api/revalidate?tag=supabase-nameを行ったところ、キャッシュデータが更新されました。

レンダリングも想定していた通りStaticに変化していました。

Route (app)                                Size     First Load JS
┌ ○ /                                      137 B          78.2 kB
├ λ /api/revalidate                        0 B                0 B
├ ○ /api/supabase/name                     0 B                0 B
└ ○ /favicon.ico                           0 B                0 B

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

あとはSupabaseのデータが更新されたタイミングでWebhookを叩くようにすれば、所望の挙動を実現できそうです。

https://supabase.com/docs/guides/database/webhooks

懸念

Route Handlersを使用した際のレンダリング

想定では失敗例の実装でもStaticになる想定でしたが、実際はSSRになっていました。

  • Route Handlersを使用すると必ずSSRになるのか、それともRoute Handlersを使用してもStaticにできるのか
  • ↑が後者の場合は、失敗例ではなぜSSRされていたのか

このあたりは明らかに理解不足なので勉強します。

getSupabaseの実装について

所望の動作は実現できたものの、fetchする度にcreateClientすることになっている点は気になります。issueについているコメントを確認する限りでは問題はなさそうにも見えますが、気に留めておく必要はありそうです。

他参考(本文中に掲載しなかったもの)

https://zenn.dev/cybozu_frontend/articles/server-actions-and-revalidate

https://zenn.dev/cybozu_frontend/articles/next-caching-dedupe

https://zenn.dev/yu_undefined/scraps/ee259f6dd080a5#comment-59a3394dd30df9

Discussion

ちぢちぢ

この記事のおかげで留年せずに済みそうです。本当にありがとうございます。