Supabaseからのデータ取得をNext.jsのrevalidatePath/revalidateTagを使用してキャッシュ制御する
背景
Supabase上のデータをNext.js(App Router)で表示するサイトを作成しています。
ただし、データは頻繁に更新されないのでページにアクセスする度にデータを都度取得するのではなく、データが更新されたタイミングでページをビルドし直すようにしたいと考えました。
調べたところ、Next.js v13.4で追加されたrevalidatePath/revalidateTagが利用できそうでしたが、Supabaseと組み合わせる際に躓いたので記事として残しておきます。
Supabase
SupabaseはpostgreSQLベースのオープンソースのBaaSです。
revalidatePath/revalidateTag
revalidatePath/revalidateTagはNext.js v13.4で追加されたキャッシュ制御方法です。
revalidatePathはpathに関連付けられたデータを、revalidateTagはtagに関連付けられたキャッシュデータを再検証時間の終了を待たずに更新することができます。
失敗例
下記のnot-workingブランチとしてもまとめています。
はじめに、Supabaseからデータを取得する処理を書きます。
そのままだとfetchで書くことができない?ので、一旦Route Handlersを使用して記述した後、それをfetchで呼ぶような記述にしてみます。もう既に実装に違和感がありますね...
また、キャッシュが更新されているかを簡単に確認できるようにレスポンスにはDate.now()
を含めておきます。
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);
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を使用します。
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
には最低限の内容を書きます。
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を更新するためのメソッドを書きます。
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
であるため
- Next.jsのfetchのデフォルト動作は
-
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にたどり着きました。
結論としては、SupabaseのCutom fetchを変更する方法を使用するとのことです(デフォルトはcross-fetch)。
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ブランチとしてもまとめています。
はじめにCustom fetchの設定を行います。
その際、引数としてrevalidateTagsを受け取れるようにしておきます。
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
は削除)。なんとなく感じていた失敗例の実装の違和感も払拭されました。
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を叩くようにすれば、所望の挙動を実現できそうです。
懸念
Route Handlersを使用した際のレンダリング
想定では失敗例の実装でもStaticになる想定でしたが、実際はSSRになっていました。
- Route Handlersを使用すると必ずSSRになるのか、それともRoute Handlersを使用してもStaticにできるのか
- ↑が後者の場合は、失敗例ではなぜSSRされていたのか
このあたりは明らかに理解不足なので勉強します。
getSupabaseの実装について
所望の動作は実現できたものの、fetchする度にcreateClient
することになっている点は気になります。issueについているコメントを確認する限りでは問題はなさそうにも見えますが、気に留めておく必要はありそうです。
他参考(本文中に掲載しなかったもの)
Discussion
この記事のおかげで留年せずに済みそうです。本当にありがとうございます。