Next.js Supabase TanstackでのCRUDについて
2024/6/28 2024/6/28 システム
はじめに
モダンな開発環境におけるCRAD操作一本作成するのはなかなか構成に迷ったり、各ライブラリ間の組み合わせに躓いたりすることもあるかと思います。
そこで、この記事では入門向けに掲題の技術スタックにおけるCRUD操作の一例をご紹介します。
SSRとCRSの使い分けについて
本記事では、クライアントサイドでのレンダリング速度の高速化を図るため、ページ単位ではSSRを使用し、登録画面や編集画面等のstateの変化が発生する画面やコンポーネントではCRSを利用する方針でいきます。
TanstackでQueryClientのインスタンスをサーバーサイドとクライアントサイドで適切に管理するために汎用的な関数を作っておきます
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query),
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
} else {
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
また、supabaseのライブラリは現在は@supabase/ssrが推奨となっています。
古い記事だと別のやり方で記載されているため注意です。
こちらもサーバーサイドとクライアントサイド用に関数を分離しておきます。
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
let supabaseClient: ReturnType<typeof createServerClient> | null = null;
export function createServerSupabaseClient() {
if (supabaseClient) {
return supabaseClient;
}
const cookieStore = cookies();
supabaseClient = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
console.error("Error setting cookie:", error);
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: "", ...options });
} catch (error) {
console.error("Error removing cookie:", error);
}
},
},
},
);
return supabaseClient;
}
import { createBrowserClient } from "@supabase/ssr";
let supabaseClient: ReturnType<typeof createBrowserClient> | null = null;
export function createBrowserSupabaseClient() {
if (supabaseClient) {
return supabaseClient;
}
supabaseClient = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
return supabaseClient;
}
page.tsxではSSRを行うため、prefetchQueryでデータを取得します。取得部分の関数化は割愛します。
サーバーサイドで取得したデータをクライアントサイドにも渡せるように、dehydratedStateに格納し、受け渡すコンポーネントをHydrationBoundaryでwrapします。
const supabase = createServerSupabaseClient();
const queryClient = getQueryClient();
void queryClient.prefetchQuery(anyApi(supabase));
const dehydratedState = dehydrate(queryClient);
...
<HydrationBoundary state={dehydratedState}>
<Component />
</HydrationBoundary>
CSRでのデータフェッチ
SSRの時点で初回のデータフェッチをしているため、CSRでのデータ取得はキャッシュを用いて取得することができ高速で描画できます。
CSRでのデータ取得はuseQueryを使ってもいいですが、Tanstackを使っているならloading状態の管理などが楽になるuseSuspenseQueryがおすすめです。
const { data } = useSuspenseQuery(anyApi(supabase));
useSuspenseQueryを使う際は、本来useQueryで必要だった以下のような状態管理が不要になります
if(isLoading) {
return (
<Loader>
)
}
その代わり、suspenseQueryで取得したデータを利用するコンポーネントを<Suspense />でwrapします。具体的な使用イメージは以下です。
import React, { Suspense } from "react";
...
<Suspense fallback={<Loader />}>
<Component data={data}/>
</Suspense>
CSRで複数のクエリーを実行したい場合は、useSuspenseQueryを羅列するのではなく、useSuspenseQueriesという関数があるためそちらを使います。
const [{ data: dataA }, { data: dataB }] = useSuspenseQueries({
queries: [
anyApi1(supabase),
anyApi2(supabase),
],
});
データ更新時
データを更新する際はmutationを使います。
TanstackやReact Queryではお馴染みですが、更新したデータは再取得する必要はなく、取得した際のqueryKeyを破棄することで自動的に再取得を行なってくれます。
const mutation = useMutation<void, Error, FormData>({
mutationFn: async (data: FormData) =>
api.anyUpdate(supabase, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keyName"] });
},
onError: (error: any) => {
},
});
const onSubmit = handleSubmit((data: FormData) => {
mutation.mutate(data);
});
SupabaseClientでのデータ取得方法
冒頭部分で割愛したsupabaseでのデータ取得や更新についても簡単にですが記載します。
const fetchPosts = async (client: SupabaseClient): Promise<Posts[]> => {
const { data, error } = await client
.from("Posts")
.select("id, name, image")
.order("order", { ascending: true });
if (error) {
throw new Error("Error fetching categories: " + error.message);
}
return data;
};
上記はPostsというテーブルを例として、id, name, imageを取得しています。
取得順はorderを使って制御することができます。
絞り込みは.epメソッドを使用し、引数にカラム名、変数を指定します
const fetchPosts = async (
client: SupabaseClient,
user_id: string | undefined,
): Promise<Posts[]> => {
const { data, error } = await client
.from("Posts")
.select("id, title")
.eq("user_id", user_id);
if (error) {
throw new Error("Error fetching services: " + error.message);
}
return data;
};
単一のデータを一件取得する際は.single()を利用します。
SupabaseClientでのデータ更新方法
const updatePosts = async (supabase: SupabaseClient, data: any) => {
const { error } = await supabase
.from("Posts")
.update([data])
.eq("id", data.id);
if (error) {
throw new Error(error.message);
}
};
このように.selectや.update, .deleteといったメソッドを指定してテーブルを操作します。
CSRからSupabaseに触れる際の注意点
本記事の例ではSSRでもCSRでもクラウドのAPIにアクセスを行うため、仮にanonキーがわかってしまえば誰でもDBの操作が可能な状態となってしまうため、SupabaseではRLSという行レベルでのアクセス制御の設定を加えるのが不可欠となります。
RLSの設定例はこのようになります
CREATE POLICY "自身のIDを含むデータのみINSERT可能"
ON "Posts"
FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "自身のIDを含むデータのみUPDATE可能"
ON "Posts"
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "自身のIDを含むデータのみDELETE可能"
ON "Posts"
FOR DELETE
USING (auth.uid() = user_id);
CREATE POLICY "認証済みユーザへSELECTを許可"
ON "Posts"
AS PERMISSIVE
FOR SELECT
TO authenticated
using (true);
例えばログイン機能を持つ閉鎖的ブログ投稿サイトでのRLSの設定だとすると、上記のように設定することで
- ログインしているユーザーのみ記事の閲覧が可能
- 新規記事を投稿する際は、投稿しようとしているレコードのuser_idの値が自身の認証情報と一致している場合のみ可能
- 記事の編集をする際は、編集対象のレコードのuser_idが自身の認証情報と一致するもののみ可能
- 記事の削除は自身のIDを含むレコードのみ可能
といった制御を加えることができます。
Supabaseからもドキュメントを閲覧したり、追加したいLRSをテンプレートから選択してGUIでカスタマイズしていくこともできますので、詳しく見てみるのがおすすめです。