🧑‍💻

【Supabase】SingUp→確認メール→本登録の導線の実装

に公開

概要

バックエンドに自信がないのもあり、Supabaseをよく使っています。
Supabase Authにおいて、メールアドレス×パスワードで認証機能を実装した時、入力してもらったメールアドレスに確認メールが送られます。そのメールに記載のリンクを踏んでもらうことで本登録となります。
今回は、そのリンクを踏んだ後にコールバック画面に遷移するようにし、そこでSupabase Databaseのusersテーブルにデータ登録をする、という導線の実装をしていこうと思います。

開発環境

  • Next.js(App Router)
  • daisy UI:コード例にはdaisy UIのクラス名が含まれています
  • Supabase(Auth, DB)
    • 事前にSupabase でプロジェクトの作成を済ませておいてください
    • また、Databaseにusersテーブルの作成をお願いします
      • カラム例:id, email, username, created_at, updated_at

実装

流れの概要
1, /sign-up 画面でメールアドレス・パスワード・ユーザーネームを入力させ、sign up処理実行
  ↓
2, 入力したメールアドレスに確認メールが送られる
  ↓
3, メールに記載のリンクをクリック
  ↓
4, /auth/callback 画面に遷移
  ↓
5, Supabase Databaseのusers テーブルにデータ作成
  ↓
6, /auth/complete 画面に遷移させる

sign up処理

  • sign up(会員登録/新規登録)画面を作成し、メールアドレス・パスワード・ユーザーネームの入力欄を作成してください
  • sign up処理は、サーバーアクションで実行します
フロントのコード例:daisy UIを使用
"use client";

import { useActionState } from "react";
import Link from "next/link";
import { signUp } from "@/actions/auth/signUp";

const initialState = {
  message: "",
  isSignUpComplete: false,
};

export default function SignUpForm() {
  const [state, formAction, pending] = useActionState(
    signUp,
    initialState
  );

  return (
    <div className="hero bg-base-200 min-h-screen">
      {state.isSignUpComplete ? (
        <div className="px-4">
          <div className="card bg-base-100 w-full max-w-lg shadow-md">
            <div className="card-body">
              <h2 className="card-title">メールの確認をお願いします!</h2>
              <p>
                ご入力いただいたメールアドレス宛に確認メールを送信しました。
                <br />
                メール内のリンクをクリックしてアカウント登録を完了させてください。
              </p>
            </div>
          </div>
        </div>
      ) : (
        <div className="hero-content flex-col lg:flex-row-reverse">
          <div className="text-center lg:text-left">
            <h1 className="text-5xl font-bold">新規登録</h1>
            <p className="py-6">
              すでにアカウントをお持ちの方は、
              <Link href={"/login"} className="link link-hover underline">
                こちら
              </Link>
              からログインしてください
            </p>
          </div>
          <div className="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
            <div className="card-body">
              <div className="flex w-full flex-col">
                <form action={formAction} className="fieldset">
                  <label className="label">ユーザーネーム</label>
                  <input
                    type="text"
                    name="username"
                    className="input"
                    placeholder="tsundoku-quest"
                  />
                  <label className="label">メールアドレス</label>
                  <input
                    type="email"
                    name="email"
                    className="input"
                    placeholder="tsundoku-quest@example.com"
                  />
                  <label className="label">パスワード</label>
                  <input
                    type="password"
                    name="password"
                    className="input"
                    placeholder="パスワード"
                  />
                  {state?.message && (
                    <p className="text-red-500">{state?.message}</p>
                  )}
                  {pending ? (
                    <button
                      className="btn mt-4"
                      disabled={pending}
                      tabIndex={-1}
                      role="button"
                      aria-disabled="true"
                    >
                      <span className="loading loading-spinner"></span>
                      処理中...
                    </button>
                  ) : (
                    <button className="btn btn-neutral mt-4">
                      アカウント作成
                    </button>
                  )}
                </form>
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

サーバーアクションの例

./actions/auth/signUp
"use server";

import { createClient } from "@/lib/supabase/server";

type State = {
  message: string;
  isSignUpComplete: boolean;
};

export async function signUp(
  prevState: State,
  formData: FormData
): Promise<State> {
  const supabase = await createClient(); // 別ファイルで定義したものをimport

  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const username = formData.get("username") as string;

  if (!email || !password || !username) {
    return {
      message: "ユーザーネーム、メールアドレス、パスワードは入力必須です。",
      isSignUpComplete: false,
    };
  }

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SUPABASE_SIGNUP_EMAIL_REDIRECT_URL}/auth/callback`,
      data: {
        username,
      },
    },
  });

  // 以下省略(signUpのレスポンスがエラーの時の処理を書いてます)
}

今回重要になってくるのが、await supabase.auth.signUp()処理に渡す引数の中のoptionsです

  • emailRedirectTo
    • 送られてきたメールに記載のリンクをクリックした時、リダイレクトされ遷移するURL
    • 今回は/auth/callbackに遷移するように設定
    • 上記コードは、ドメイン部分は環境によって変わる想定で、環境変数で管理しています
  • data
    • ここに設定した値は、Supabase Authの各認証ユーザーのメタデータの中に格納されます
    • /auth/callbackに遷移した時に格納した値を取得し、usersテーブルにデータ登録する時に使用します

/auth/callback 画面

  • 入力したメールアドレスに送られてきたメールに記載されたリンクをクリックした後、/auth/callbacck画面にリダイレクトされます
  • ここでは、/auth/callbacck画面の処理を見ていきます
app/auth/callback/page.tsx
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";

import Loading from "@/components/ui/Loading";

export default function AuthCallbackPage() {
  const router = useRouter();
  const supabase = createClient();

  useEffect(() => {
    const handleCallback = async () => {
      // 1. SupabaseのURLハッシュ(#access_tokenなど)を処理してセッション確立
      const {
        data: { session },
        error,
      } = await supabase.auth.getSession();
      if (error || !session) {
        console.error("セッション取得失敗:", error);
        return router.push("/auth/error");
      }

      // 2️. ユーザー情報取得(user_metadataからusernameを取得)
      const { data: userData } = await supabase.auth.getUser();
      const username = userData.user?.user_metadata?.username ?? "";

      // 3️. APIに登録リクエスト(ルートハンドラーを使用)
      const res = await fetch(`/api/auth/callback`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username }),
      });
      if (res.ok) {
        router.push("/auth/complete");
      } else {
        router.push("/auth/error");
      }
    };
    handleCallback();
  }, [router, supabase]);

  return (
    <div className="flex justify-center items-center gap-2 h-screen bg-base-100">
      <Loading variant="spinner" />
      <p className="text-xl">アカウントを有効化しています...</p>
    </div>
  );
}
  • signUpのサーバーアクションで、options.dataプロパティの中にusernameを格納しておきました
  • 今回その値をconst username = userData.user?.user_metadata?.username ?? "";で取得
  • usernameをAPIへのリクエストに含める

/api/auth/callback ルートハンドラー

  • ここでは、Supabase Databaseのusersテーブルへの登録処理を実行します
app/api/auth/callback/route.ts
import { NextRequest } from "next/server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";

export async function POST(req: NextRequest) {
  const supabase = await createClient();

  const body = await req.json();
  const username = body.username;

  const {
    data: { user },
    error,
  } = await supabase.auth.getUser();

  if (error || !user) {
    // エラー時の処理
    console.error("ユーザー取得失敗:", error);
    redirect("/auth/error?reason=no_user");
  }

  // usersテーブルに既にデータがあるか確認
  const { data } = await supabase
    .from("users")
    .select("id")
    .eq("id", user.id)
    .single();

  // 無い場合は登録
  if (!data) {
    const { error } = await supabase.from("users").insert({
      id: user.id,
      email: user.email,
      username: username ?? "",
    });

    if (error) {
      console.error("INSERT失敗:", error);
      redirect("/auth/error?reason=insert_failed");
    }
  }

  // 完了画面に遷移させる
  redirect("/auth/complete");
}

/auth/complete 画面

  • 完了画面で本登録が完了したことをお知らせし、(例として)ホーム画面への導線を表示
app/auth/complete/page.tsx
import { Button } from "@/components/ui/buttons/Button";
import Link from "next/link";

export default function AuthCompletePage() {
  return (
    <div className="flex items-center justify-center h-screen">
      <div className="text-center space-y-3">
        <h1 className="text-2xl font-bold">本登録が完了しました!</h1>
        <p>
          アカウント作成が完了しました。
          <br />
          ホーム画面へ移動できます。
        </p>
        <Link href="/dashboard">
          <Button>ホーム画面へ</Button>
        </Link>
      </div>
    </div>
  );
}

おわり

細かいところは大分端折ってしまっので、分かりづらいところもあったかもしれません(特にcreatClient()の所とか)。。。すみません🙇‍♂️
読みづらいところも多い中、最後までお付き合いいただきありがとうございました。

Discussion