ブログタイトル

目次

いいね&コメント機能追加 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;

完成!

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

参考にさせていただいた記事

https://qiita.com/megmogmog1965/items/37d7a4a3335f2758c861

https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=nextjs

[Next.js] とりあえずsupabase触ってみる