いいね&コメント機能追加 Next.js × Spabase
以下のようなSlackのリアクションタグ?みたいなやつと一言コメントが打てる機能をブログに追加する。

spabaseを使って簡単なセッション管理を実装する。Next.jsでのspabaseの選択肢としてSupabase Authと@supabase/ssrパッケージがある。今回はcookieを用いたログインなしの簡単な実装のみなのでssrの方を使っていく。※authは非推奨になったらしい。
https://supabase.com/docs/guides/auth/server-side
npm install @supabase/supabase-js @supabase/ssrでインストールできる。
テーブルを作りRLSを設定した後、以下の3種のキーをvercelの環境変数に追加する。
NEXT_PUBLIC_SUPABASE_URL="プロジェクトのURL"
NEXT_PUBLIC_SUPABASE_ANON_KEY=Publishable key
SUPABASE_SERVICE_ROLE_KEY=Secret key
Cookieでセッション管理
サーバーサイドでCookieをセットする。
"use server";
import { cookies } from "next/headers";
export async function getUserToken() {
const store = await cookies();
const userToken = store.get("userToken")?.value;
return userToken;
}
export async function setUserToken() {
const store = await cookies();
const userToken = crypto.randomUUID();
// サーバーサイドでCookieをセット
store.set({
name: "userToken",
value: userToken,
httpOnly: true, // セキュリティのためHttpOnly
sameSite: "strict", // CSRFプロテクション
maxAge: 60 * 60 * 24 * 365, // 1年間
});
return userToken;
}
Spabase Clientの設定
Cookieのデータを扱うためのスキーマを定義する。
SpabaseのダッシュボードからTableに応じたスキーマを自動生成できる。Projectページの左サイドバーの「API Docs」→ Introduction → Generating Typeの手順。
Spabase Clientの初期化を行う。
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
export const supabase = createClient<生成した型>(supabaseUrl, supabaseAnonKey);
リアクション(Cookie)データを管理する関数
ReactionDataでリアクションするオブジェクトを識別。
contentId(投稿ページの特定)とreactionType(リアクションボタンorコメント)
prevStateではリアクションの状態を管理する。
hasReacted(リアクションがあったか)に応じて、ハートのリアクションボタンの場合、アクションがあるたびにreactionCountを+1し、リアクションがすでにされていたら-1する。
コメントの場合、hasReactedに応じてコメントを追加、更新する。
"use server";
import { supabase } from "@/lib/supabaseClient";
import { Tables } from "@/types/generting.types";
import { getUserToken, setUserToken } from "@/lib/userToken";
type ReactionData = {
contentId: string;
reactionType: Tables<"reactions">["reaction_type"];
comment?: string;
};
type prevState = {
reactionCount: number;
hasReacted: boolean;
comment?: string;
};
export const updateReaction = async (
prevState: prevState,
//contentIdとreactionTypeを引数に
reactiondata: ReactionData
) => {
let userToken = await getUserToken();
if (!userToken) {
userToken = await setUserToken();
}
// memoの場合は、コメントを更新または追加
if (reactiondata.reactionType === "memo") {
const { data, error } = await supabase
.from("reactions")
.upsert(
{
user_token: userToken,
content_id: reactiondata.contentId,
reaction_type: reactiondata.reactionType,
comment: reactiondata.comment,
},
{ onConflict: "user_token, content_id, reaction_type" }
)
.select()
.single();
if (error) {
console.error(error);
return prevState;
}
return {
hasReacted: true,
comment: reactiondata.comment,
};
}
// heartのリアクションボタン
if (prevState.hasReacted) {
// リアクションを削除
await supabase
.from("reactions")
.delete()
.eq("content_id", reactiondata.contentId)
.eq("reaction_type", reactiondata.reactionType)
.eq("user_token", userToken);
return {
reactionCount: prevState.reactionCount - 1,
hasReacted: !prevState.hasReacted,
};
} else {
// リアクションを追加
await supabase.from("reactions").insert({
user_token: userToken,
content_id: reactiondata.contentId,
reaction_type: reactiondata.reactionType,
});
return {
reactionCount: prevState.reactionCount + 1,
hasReacted: !prevState.hasReacted,
};
}
};
あとはコンポーネントに渡して完成
"use client";
import { Button } from "../atoms/Button";
import { Badge } from "../atoms/badge";
import { Tables } from "@/types/database.types";
import useReactionButtonLogic from "@/hooks/swr/useReactionButtonLogic";
import { cn } from "@/lib/utils";
const REACTION_ICONS = {
heart: "❤️🔥",
//今後リアクションのタイプを増やす
};
interface ReactionButtonProps {
contentId: string;
reactionType: Tables<"reactions">["reaction_type"];
reactionCount: number;
hasReacted: boolean;
}
const ReactionButton = (props: ReactionButtonProps) => {
const { isPending, optimisticState, handleClick } = useReactionButtonLogic(
props.reactionCount,
props.hasReacted,
props.contentId,
props.reactionType
);
return (
<Button
variant={"ghost"}
size={"sm"}
className="py-0.5 h-6.5"
asChild
// 追加
onClick={handleClick}
disabled={isPending}
>
<Badge
variant={"outline"}
// 自分がリアクションしているかどうかで、スタイルを分岐
className={cn(
"rounded-full cursor-pointer text-xl border-1 border-(--color-divider-main) text-muted-foreground hover:bg-(--color-secondary-main)",
optimisticState.hasReacted && "hover:bg-(--color-secondary-main) cursor-pointer"
)}
>
{REACTION_ICONS[props.reactionType]} {optimisticState.reactionCount}
</Badge>
</Button>
);
};
export default ReactionButton;
完成!

今のところ自分で書いたコメントしか表示されていないので、今後他のユーザーが書いたコメントも残るようにしていきたい。