😊

Next.js ハンズオン-その2: Supabaseで認証機能と掲示板機能を実装する

2024/08/28に公開

はじめに

Supabaseを用いてユーザーの新規登録・ログインなどの認証機能を実装し、ユーザーが掲示板にコメントを投稿できるようにする。

Step1. Supabaseの登録

Supabaseのアカウントがなければ、まずは https://supabase.com/ から登録する。
ログイン後、[New Project]から以下のように必要な情報を埋める。

プロジェクト作成後、以下のようにAPIキーを取得できる。

Step2. Supabaseにおいてデータベースの設計をする

今回のハンズオンで使用するデータベーステーブルの構造は、ユーザー認証と投稿機能をベースに以下のように設計することが考えられる。

どんなテーブルを作るべきか?

・usersテーブル
id: uuid - ユーザーのユニークID
email: varchar - ユーザーのメールアドレス(認証に使用)
username: varchar - ユーザーの名前
created_at: timestamp - アカウント作成日
updated_at: timestamp - アカウント更新日

・postsテーブル
id: int8 - 投稿のユニークID
user_id: uuid - 投稿者のユーザーID(usersテーブルと外部キーで関連)
title: varchar - 投稿のタイトル
content: text - 投稿の内容
created_at: timestamp - 投稿作成日
updated_at: timestamp - 投稿更新日

・commentsテーブル
id: int8 - コメントのユニークID
post_id: int8 - コメントが紐づいている投稿のID(postsテーブルと外部キーで関連)
user_id: uuid - コメントを投稿したユーザーID(usersテーブルと外部キーで関連)
content: text - コメント内容
created_at: timestamp - コメント作成日
updated_at: timestamp - コメント更新日

リレーション構造は?

・usersテーブルとpostsテーブルは1対多の関係である。つまり、1人のユーザーが複数の投稿を作成できる。
・postsテーブルとcommentsテーブルも1対多の関係である。1つの投稿に複数のコメントが付けられる形になる。

考えたDBをSupabase上に構築しよう

画面一番左のTable Editorからテーブルの編集ができる。

以下のようにして上記で考えたDBを構築する。

usersテーブルができたら[+New Table]からpostsなども追加しよう。

postsを追加する際に、postsテーブルのuser_idとusersテーブルのidを紐づける必要がある。Foreign keyというところ追加できる

commentsテーブルはこんな感じです

開発段階でメール二段階認証はめんどくさい

Authenticationの設定からオフにできます

補足: Row level securityの設定(必須)

DBを安全に取り扱うために必要。postをするにも、getをするにもこの設定をしていないとエラーを出す。例えばpostsテーブルを選択した状態で[Add RLS Policy]ボタンをクリック

以下の画面でポリシーを設定していく。[Create Policy]

認証されたユーザーのみが投稿を挿入できるようにするポリシーの例を示す。

ポリシー名: Allow authenticated insert
Action: INSERT
Target roles: authenticated
Policy definition:
(auth.uid() = user_id)

このポリシーでは、auth.uid()(現在の認証されたユーザーのID)がuser_idフィールドと一致する場合に挿入を許可する。

postsテーブルの内容を表示するときにはSELECTをする。全ユーザーがselectをできるようにrow level securityを以下のように設定する。

signupの際にuserも登録するので以下のようにusersテーブルにもrow level securityを設定しておく。

Step3. Next.jsプロジェクトを作る

$ npx create-next-app@latest supabase-handson --typescript
`$ npm install @supabase/supabase-js

プロジェクトディレクトリ構造

my-app/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── dashboard/
│   │   └── page.tsx
├── lib/
│   └── supabase.ts
├── components/
│   └── Auth.tsx
├── types
|   └──supabaseTypes.ts
└── .env.local

基本ページ

app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
app/page.tsx
import Link from "next/link";

export default function Home() {
  return (
    <div>
      <h1>Welcome to My App</h1>
      <Link href="/dashboard">Go to Dashboard</Link>
    </div>
  );
}

Step4. 認証機能の実装

SupabseでプロジェクトのHomeにあるProject APIの中に以下二つが書いてある。
.env.localに以下を記述する。

NEXT_PUBLIC_SUPABASE_URL=supabaseのProject URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=supabaseのAPI Key(anonというタグが付いているもの)
lib/supabase.ts
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);
components/Auth.tsx
'use client';

import { useState } from "react";
import { supabase } from "../lib/supabase";

export default function Auth() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [message, setMessage] = useState("");

  const signIn = async () => {
    const { user, error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
    if (error) {
      setMessage(error.message);
    } else {
      setMessage("Signed in successfully!");
    }
  };

  const signUp = async () => {
    const { data, error } = await supabase.auth.signUp({
      email,
      password,
    });

    if (error) {
      setMessage(error.message);
    } else {
      setMessage("Signed up successfully! Please check your email for confirmation.");

      // 登録成功後、ユーザー情報を`users`テーブルに挿入
      if (data?.user) {
        const { error: dbError } = await supabase.from("users").insert([
          { id: data.user.id, email: data.user.email }
        ]);

        if (dbError) {
          console.error("Error inserting user into database:", dbError.message);
        } else {
          console.log("User inserted into database successfully!");
        }
      }
    }
  };

  const signOut = async () => {
    await supabase.auth.signOut();
    setMessage("Signed out successfully!");
  };

  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button onClick={signIn}>Sign In</button>
      <button onClick={signUp}>Sign Up</button> {/* 新規登録ボタンを追加 */}
      <button onClick={signOut}>Sign Out</button>
      <p>{message}</p>
    </div>
  );
}

Step5.掲示板の実装(CRUD) + SupabaseとApp Routerでデータフェッチング

TypeScriptを使う際は型定義をしっかりとしておくことが重要だと実感...

types/supabaseTypes.ts
export interface Post {
    id: number;
    title: string;
    content: string;
    user_id: string;
    created_at: string;
    updated_at: string;
  }
app/dashboard/page.tsx
'use client';

import { useEffect, useState } from "react";
import { supabase } from "../../lib/supabase";
import Auth from "../../components/Auth";
import { Session } from "@supabase/supabase-js";
import { Post } from "../../types/supabaseTypes";

export default function Dashboard() {
  const [session, setSession] = useState<Session | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  useEffect(() => {
    // 現在のセッション情報を取得
    const getSession = async () => {
      const { data, error } = await supabase.auth.getSession();
      if (data?.session) {
        setSession(data.session);
        fetchPosts();  // セッションが存在する場合に投稿データを取得
      } 
    };

    getSession();

   // 認証の状態が変わった際にセッション情報を更新
   const { data: authListener } = supabase.auth.onAuthStateChange(
    (_event, sessionData) => {
      if (sessionData && sessionData) {  // sessionDataがnullでないことを確認
        setSession(sessionData);
        fetchPosts();  // セッションが変わった場合も投稿データを取得
      } else {
        setSession(null);  // sessionDataがnullの場合はセッションをリセット
      }
    });

    return () => {
      authListener?.subscription.unsubscribe();
    };
  }, []);

  const fetchPosts = async () => {
    const { data } = await supabase.from("posts").select("*");
    setPosts(data || []);
  };

  const createPost = async () => {
    if (!session) return;
    await supabase.from("posts").insert([
      { title, content, user_id: session.user.id },
    ]);
    fetchPosts();  // 投稿を作成後、データを再取得
  };

  if (!session) {
    return <Auth />;
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user?.email}</p>
      <div>
        <h2>Your Posts</h2>
        {posts.map((post) => (
          <div key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.content}</p>
          </div>
        ))}
      </div>
      <div>
        <h2>Create New Post</h2>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Title"
        />
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Content"
        />
        <button onClick={createPost}>Create Post</button>
      </div>
    </div>
  );
}

DBにデータの登録とその表示ができるようになった!

Discussion