Remix, Supabaseでサインアップ&ログイン処理を実装してみた感想

# はじめに
はじめまして。
24卒でIT企業に就職した23歳です。プログラミングは大学から初めたので4年程度やっています。
初記事です。お願いします。

個人開発にてRemixとSupabaseを使ってサインアップ&ログイン機能を実装してみたので、手順の紹介と感想です。
手順というかコードの紹介と感想に近いですがご了承ください。

※React自体ほぼ初めてレベル（業務では触らない）なので、間違いや情報が古いなどありましたら、コメントで指摘していただけると助かります。
# 概要
本記事では、以下の3つのページを実装します。
- **ログインページ**: 既存ユーザーがログインできるページ```/login```
- **サインアップページ**: 新規ユーザー登録ページ```/signup```
- **ホームページ**: ログイン後にアクセスできるユーザー専用のページ```/home```

# Supabaseの設定
Supabaseのプロジェクトを作成し、APIキーとURLを取得します。
vite環境だとクライアント側でprocessが使えないっぽい？ので```import.meta.env.~~~```を使っています。```.env```の変数には```VITE_```のprefixがないとうまく動かなかったです。
```.env
VITE_SUPABASE_URL=your-supabase-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
```

Supabaseを利用するためのコードを追加します。
```app/components/lib/supabase.ts
import { createServerClient } from "@supabase/auth-helpers-remix";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL!;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

export const getSupabaseHeaders = (request: Request, response: Response) => {
    return createServerClient(supabaseUrl, supabaseAnonKey, { request, response });
}
```

今回はサインアップしたユーザをDBで管理しようと思うので、Supabase上にテーブルを用意しました。
※個人開発の都合上使っていない無駄なカラムも書いてあります。

| Column | Type | Nullable |
|:-:|:-:|:-:|
| userid  |  varchar | false  |
| name  |  varchar | false  |
| mail  |  varchar | false  |
| created_at  |  timestamp | false  |
| password  |  varchar | false  |

# ログイン
```app/routes/login.tsx
import type { ActionFunction, LoaderFunction, MetaFunction } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import Login from "~/components/Login/Login";
import { redirect } from "@remix-run/node";
import { getSupabaseHeaders } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "ログイン | cosmoke" },
    { name: "description", content: "ログインページ" },
  ];
};

// すでにログインしている場合は /home にリダイレクト
export const loader: LoaderFunction = async ({ request }) => {
  const response = new Response();
  const supabase = getSupabaseHeaders(request, response);

  const { data: { session } } = await supabase.auth.getSession();

  if (session) {
    return redirect("/home");
  }

  return response;
};

// ログイン処理
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const response = new Response();
  const supabase = getSupabaseHeaders(request, response);

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) {
    return Response.json({ error: error.message }, { status: 400 });
  }

  return redirect("/home", {
    headers: response.headers,
  });
};

export default function Index() {
  const actionData = useActionData<{ error?: string }>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <>
      <div className="flex justify-center items-center w-screen h-screen">
        <Form method="post">
          <Login error={actionData?.error} isSubmitting={isSubmitting} />
        </Form>
      </div>
    </>
  );
}
```
### すでにログイン状態の処理
LoaderFunctionでページを表示する前にログイン状態かを確認し、ログイン中であれば```/home```にリダイレクトさせます。
ログイン状態はセッションでの管理にしているので```supabase.auth.getSession();```で、セッションを取得し、ログイン状態を判別して```/home```にリダイレクトします。

### ログイン処理
フォームから取得したデータに対して、```supabase.auth.signInWithPassword();```でログインを行います。
ログイン後は```/home```にリダイレクトします。

### UI

コンポーネントについても記載しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641074/1780a618-b2d6-954c-d771-a97287f183c1.png)

```app/components/Login/Login.tsx
import React from 'react';
import { Input } from '~/components/ui/input';
import {
    Card,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
} from "~/components/ui/card"
import { Button } from '~/components/ui/button';

interface LoginProps {
    error?: string;
    isSubmitting: boolean;
}

function Login({ error, isSubmitting }: LoginProps) {
    return (
        <>
            <Card>
                <CardHeader>
                    <CardTitle className='text-center text-lg'>ログイン</CardTitle>
                </CardHeader>
                <CardContent>
                    <p className='text-sm mb-2 font-bold'>メールアドレス</p>
                    <Input placeholder="Mail" type='email' name='email' required />
                </CardContent>
                <CardContent>
                    <p className='text-sm mb-2 font-bold'>パスワード</p>
                    <Input placeholder="Password" type='password' name='password' required />
                </CardContent>
                {error && (
                    <CardContent>
                        <p className="text-red-500 text-sm">{error}</p>
                    </CardContent>
                )}
                <CardContent>
                    <Button
                        type='submit'
                        className='w-full'
                    >
                        {isSubmitting ? 'ログイン中...' : 'ログイン'}
                    </Button>
                </CardContent>
                <CardFooter>
                    <p className='mx-auto text-sm'>
                        <a href='/signup' className='text-primary underline'>会員登録はこちら</a>
                    </p>
                </CardFooter>
            </Card>
        </>
    );
}

export default Login;
```

# サインアップ
```app/routes/signup.tsx
import type { ActionFunction, MetaFunction } from "@remix-run/node";
import Signup from "~/components/Signup/Signup";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { supabase } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "会員登録" },
    { name: "description", content: "会員登録ページ" },
  ];
};

// サインアップ処理
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const userid = formData.get("userid") as string;

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

  if (error) {
    return Response.json({ error: error.message }, { status: 400 });
  }

  const { error: dbError } = await supabase
    .from('users')
    .insert([
      {
        userid: userid,
        name: userid,
        mail: email,
        password: password,
      },
    ])
    .select();

  if (dbError) {
    return Response.json({ error: dbError.message }, { status: 500 });
  }

  return {
    redirect: "/login",
  };
}

export default function Index() {
  const actionData = useActionData<{ error?: string }>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <>
      <div className="flex justify-center items-center w-screen h-screen">
        <Form method="post">
          <Signup error={actionData?.error} isSubmitting={isSubmitting} />
        </Form>
      </div>
    </>
  );
}
```
### サインアップ処理
フォームから取得したデータを使って```supabase.auth.signup();```でサインアップを行います。
このとき、登録したメールアドレスにSupabaseからメールが届くので、リンクを開かないとログインできないです。少しハマりました。

上記とは別に、Supabaseに作成しておいたテーブルにユーザデータを登録してあります。

### UI
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/641074/6d83a03d-00d6-76a2-977b-4479101370d5.png)

```app/components/Signup/Signup.tsx
import React from 'react';
import { Input } from '~/components/ui/input';
import {
    Card,
    CardContent,
    CardDescription,
    CardFooter,
    CardHeader,
    CardTitle,
} from "~/components/ui/card"
import { Button } from '~/components/ui/button';

interface SignupProps {
    error?: string;
    isSubmitting: boolean;
}

function Signup({ error, isSubmitting }: SignupProps) {
    return (
        <>
            <Card>
                <CardHeader>
                    <CardTitle className='text-center text-lg'>会員登録</CardTitle>
                </CardHeader>
                <CardContent>
                    <p className='text-sm mb-2 font-bold'>
                        メールアドレス<span className='text-red-500 text-xs'>&nbsp;必須</span>
                    </p>
                    <Input placeholder="Mail" type='email' name='email' required />
                </CardContent>
                <CardContent className='relative'>
                    <p className='text-sm mb-2 font-bold'>パスワード<span className='text-red-500 text-xs'>&nbsp;必須</span></p>
                    <Input placeholder="Password" type="password" name='password' required />
                </CardContent>
                <CardContent className='relative'>
                    <p className='text-sm mb-2 font-bold'>ユーザーID<span className='text-red-500 text-xs'>&nbsp;必須</span></p>
                    <Input placeholder="UserID" name='userid' required />
                </CardContent>
                {error && ( 
                    <CardContent>
                        <p className="text-red-500 text-sm">{error}</p>
                    </CardContent>
                )}
                <CardContent>
                    <Button className='w-full' type='submit' disabled={isSubmitting}>
                        {isSubmitting ? '登録中...' : '登録する'}
                    </Button>
                </CardContent>
                <CardFooter>
                    <p className='mx-auto text-sm'>
                        <a href='/login' className='text-primary underline'>ログインはこちら</a>
                    </p>
                </CardFooter>
            </Card>
        </>
    );
}

export default Signup;
```

# ログイン中のみ閲覧可能ページ
```routes/home.tsx
import { redirect, LoaderFunction, MetaFunction, ActionFunction } from "@remix-run/node";
import Logout from "~/components/Logout/Logout";
import { Form } from "@remix-run/react";
import { getSupabaseHeaders } from "~/lib/supabase";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

// ログインしていない場合は /login にリダイレクト
export const loader: LoaderFunction = async ({ request }) => {
  const response = new Response();
  const supabase = getSupabaseHeaders(request, response);

  const { data: { session }} = await supabase.auth.getSession();

  if (!session) {
    return redirect("/login", { headers: response.headers });
  }

  return response;
};

// ログアウト処理
export const action: ActionFunction = async ({ request }) => {
  const response = new Response();
  const supabase = getSupabaseHeaders(request, response);

  await supabase.auth.signOut();

  return redirect("/login", {
    headers: response.headers,
  });
};

export default function Index() {
  return (
    <>
      <h1 className="text-3xl">You are Logined!</h1>
      <Form method="post">
        <Logout />
      </Form>
    </>
  );
}
```

### 未ログインでアクセスしたときの処理
```supabase.auth.getSession();```でセッションを取得し、セッションを持っていなければ```/login```にリダイレクトさせます。

### ログアウト処理
```supabase.auth.signOut();```でログアウトしています。

### UI
ここはボタンを置いているだけなので割愛します。

# 感想
最後まで読んでいただきありがとうございます。
思ったよりも時間はかかりましたが、認証機構の難しい部分はほとんどRemixやSupabase側が行ってくれていてコード自体は直感的に理解できるものになったなあと思いました。
Reactを触っていた人がRemixを触るときっと扱いやすさに感動したりするのかなと思いましたが、RemixでReactをほぼ初めて触っているのでわからないのが悔しいです。


### さいごに
この記事は間違いがとても多いと思うので、正しい知識を身につけ次第更新しようと思います。