Next.js+supabaseで認証する


## はじめに

Next.js v13 (not app dir) でsupabaseの認証機能を一通り試していきたいと思います。

要件は以下の通りです。

|path|  | 機能 |
|---|---|---|
|`/`| トップページ | ログイン済みの場合、マイページへのリンクを記載。<br >そうでない場合、認証画面へのリンクを記載|
|`/auth` | 認証画面 | ログインやユーザー作成を行う。<br />ログイン済みの場合はマイページにリダイレクト |
| `/mypage` | マイページ | ログイン中のメアドを表示<br /> ログインしていない場合は認証画面へリダイレクト<br />ログアウトボタンの設置(ログアウト後にトップへリダイレクト) |
| `/mypage/*` | 個人の情報を参照したり変更したりするページ | ログインしていない場合は認証画面へリダイレクト |


## 環境

- node: v18.12.1

```json
{
  "dependencies": {
    "@supabase/auth-helpers-nextjs": "^0.5.2",
    "@supabase/auth-helpers-react": "^0.3.1",
    "@supabase/auth-ui-react": "^0.2.6",
    "@supabase/supabase-js": "^2.2.1",
    "next": "13.0.7",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "18.11.17",
    "@types/react": "18.0.26",
    "@types/react-dom": "18.0.9",
    "autoprefixer": "^10.4.13",
    "eslint": "8.30.0",
    "eslint-config-next": "13.0.7",
    "postcss": "^8.4.20",
    "tailwindcss": "^3.2.4",
    "typescript": "4.9.4"
  }
}
```

## Supabase側の準備

`SQL Editor`に`User Management Starter`というクイックスターターがあるので、そちらを選択します。


## sessionProviderの追加

`pages/_app.tsx`を以下のように変更します。

```pages/_app.tsx

import { AppProps } from "next/app";
import { useState } from "react";
import { createBrowserSupabaseClient } from "@supabase/auth-helpers-nextjs";
import { SessionContextProvider, Session } from "@supabase/auth-helpers-react";
import "styles/globals.css";

function MyApp({
  Component,
  pageProps,
}: AppProps<{
  initialSession: Session;
}>) {
  // Create a new supabase browser client on every first render.
  const [supabaseClient] = useState(() => createBrowserSupabaseClient());

  return (
    <SessionContextProvider
      supabaseClient={supabaseClient}
      initialSession={pageProps.initialSession}
    >
      <Component {...pageProps} />
    </SessionContextProvider>
  );
}

export default MyApp;

```

こうすることで、`@supabase/auth-helpers-react`の`useUser`が利用できるようになります。
useRouterは、認証情報を取得するために使用します。


## 認証用のコンポーネントを用意する



Supabaseは認証画面を表示する`@supabase/auth-ui-react`というライブラリを用意してくれています。
手間をかけたくないので、今回はこちらを利用します。

```lib/supabaseClient.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 supabaseClient = createClient(supabaseUrl, supabaseAnonKey)
```

```components/Auth.tsx
import {
  Auth as SupabaseAuthComponent,
  ThemeSupa,
} from "@supabase/auth-ui-react";
import { supabaseClient } from "lib/supabaseClient";
import styles from "components/Auth.module.css";

export const Auth = () => (
  <SupabaseAuthComponent
    supabaseClient={supabaseClient}
    appearance={{
      theme: ThemeSupa,
      className: {},
      style: {},
    }}
    providers={[]}
  />
);

export default Auth;
```

ひとまず、最低限の設定だけをしてみました。
これだけでどんな画面になるのか、試しに適当な`/page`配下でimportして使ってみましょう。

```/page/hoge.tsx
import { NextPage } from "next";
import { Auth } from "components/Auth";

const Hoge: NextPage = () => {
  return <Auth />;
};

export default Hoge;
```

出力結果はこんな感じです。
GoogleやTwitterといったProviderを設定していないのでパスワード認証だけですが、いい感じに最低限ができています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532032/3a1dae5e-392f-46d9-bab6-aedd74d41a10.png)

これをちょっと調整して、`/auth`を作成します。

```components/Auth.tsx
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import {
  Auth as SupabaseAuthComponent,
  ThemeSupa,
} from "@supabase/auth-ui-react";

export const Auth = () => {
  const supabaseClient = useSupabaseClient();

  return (
    <SupabaseAuthComponent
      supabaseClient={supabaseClient}
      appearance={{
        theme: ThemeSupa,
        className: {
          container: "w-full max-w-sm md:max-w-md mx-auto",
        },
        style: {
          container: {
            marginRight: "auto",
            marginLeft: "auto",
          },
        },
      }}
      localization={{
        lang: "ja",
      }}
    />
  );
};

export default Auth;
```

```pages/auth.tsx
import { NextPage } from "next";
import Auth from "components/Auth";

const AuthApp: NextPage = () => {
  return (
    <div className="h-full w-full min-h-screen place-content-center pt-32">
      <Auth />
    </div>
  );
};

export default AuthApp;
```

その結果がこちらになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532032/ce1bc0d3-5b35-a519-19da-3cc144d0e5e8.png)

いい感じに描画できていますね。
ただ、これには一点問題があります。

ユーザー作成時にはこれで問題ないのですが、ユーザーログイン時に問題があるのです。

一般に、ユーザーがログインした際の挙動は、認証が通ったら適当なページにリダイレクトするという形かと思います。
しかし、上記までのコードではログインした段階ではリダイレクトを行いません。
そのため、`_app.tsx`で話に挙げた`useUser`を利用してログインを検知し、適当なページにリダイレクトさせる必要があります。

```diff_typescript
import { NextPage } from "next";
import Auth from "components/Auth";
+ import { useUser } from "@supabase/auth-helpers-react";
+ import { useEffect } from "react";
+ import { useRouter } from "next/navigation";

const AuthApp: NextPage = () => {
+  const user = useUser();
+  const router = useRouter();

+  useEffect(() => {
+    if (user) router.replace("/mypage");
+  }, [user]);

  return (
    <div className="h-full w-full min-h-screen place-content-center pt-32">
      <Auth />
    </div>
  );
};

export default AuthApp;

```

`useUser()`は認証情報を追従してくれるので、`useEffect`を用いてそのタイミングに合わせてリダイレクトさせます。

これでログインは完了です。

ついでに、このままだとログイン状態で`/auth`にアクセスするとリダイレクト前に一瞬画面が移ってダサいので、getServerSidePropsを使用してリダイレクトさせます。

```pages/auth.tsx
import { GetServerSidePropsContext, NextPage } from "next";
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
import Auth from "components/Auth";
import { useUser } from "@supabase/auth-helpers-react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";

const AuthApp: NextPage = () => {
  const user = useUser();
  const router = useRouter();

  useEffect(() => {
    if (user) router.replace("/mypage");
  }, [user]);

  return (
    <div className="h-full w-full min-h-screen place-content-center pt-32">
      <Auth />
    </div>
  );
};

export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  const supabase = createServerSupabaseClient(ctx);
  const {
    data: { session },
  } = await supabase.auth.getSession();
  if (session)
    return {
      redirect: {
        destination: "/mypage",
        permanent: false,
      },
    };
  return {
    props: {},
  };
};

export default AuthApp;
```

サーバーサイド側での認証情報へのアクセスは、`createServerSupabaseClient`を使用します。

## トップページで表示を出しわける

これもクライアント側でもサーバーサイド側でもどうにかできますが、やはり一瞬異なる表示が出ることは避けたいのでサーバーサイド側で処理します。
理屈としては↑と同じで、異なるのはリダイレクトではなくPropsの中身が異なるということだけです。

```/pages/index.tsx
import type {
  GetServerSidePropsContext,
  InferGetServerSidePropsType,
  NextPage,
} from "next";
import Link from "next/link";
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";

type Props = InferGetServerSidePropsType<typeof getServerSideProps>;

const Home: NextPage<Props> = ({ email }) => {
  return (
    <main className="p-8">
      {email ? (
        <>
          <p>Welcome {email}!</p>
          <Link href={"/mypage"}>Go to Mypage</Link>
        </>
      ) : (
        <>
          <p>please Login</p>
          <Link href={"/auth"}>Go to Auth</Link>
        </>
      )}
    </main>
  );
};

export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  // Create authenticated Supabase Client
  const supabase = createServerSupabaseClient(ctx);
  // Check if we have a session
  const {
    data: { session },
  } = await supabase.auth.getSession();
  if (session) {
    return {
      props: {
        email: session.user?.email || "",
      },
    };
  }
  return {
    props: {
      email: "",
    },
  };
};

export default Home;
```

これでログインしているか否かによって表示が変化します。

## 特定のへのアクセスの場合一律でリダイレクトさせる

`/mypage/*`の場合一律でリダイレクトさせる必要があります。
これには、nextjsのmiddlewareを用います。

```middleware.ts
import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()
  const supabase = createMiddlewareSupabaseClient({ req, res })
  const {
    data: { session },
  } = await supabase.auth.getSession()

  if (session?.user.email) {
    return res
  }

  const redirectUrl = req.nextUrl.clone()
  redirectUrl.pathname = '/auth'
  return NextResponse.redirect(redirectUrl)
}

export const config = {
  matcher: '/mypage/:path*',
}

```

理屈的にはこれまでgetServerSidePropsで行ってきた処理をmiddlewareで行わせているだけです。
これで、認証情報を持っていない状態で`/mypage/*`へ来た場合は`/auth`へリダイレクトされます。

## ログアウト処理

残るはマイページでのログアウト機能の実装です。
ログアウトは、`auth.signOut()`関数が用意されているのでそちらを使用します。

```/pages/mypage/index.tsx
import { NextPage } from "next";
import { useRouter } from "next/navigation";
import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react";
import { useEffect } from "react";

export const Mypage: NextPage = () => {
  const router = useRouter();
  const user = useUser();
  const supabaseClient = useSupabaseClient();

  useEffect(() => {
    if (!user) router.push("/");
  }, [user]);

  return (
    <div>
      <p>{user?.email}</p>
      <button
        onClick={async () => {
          await supabaseClient.auth.signOut();
        }}
      >
        Logout
      </button>
    </div>
  );
};

export default Mypage;

```

なお、上記のコードではログアウト後の離脱処理をuseEffectで行っていますが、別にonClickの際の挙動としても良いです。
というか私が気にしいでuseEffectに持たせているだけなので、onClickの挙動に含んだ方がよりシンプル化と思います。

## おわりに

とりあえずこれで最低限の認証の流れは済みました。
ドキュメント見る感じSMS認証とかもあるので、時間があるときにもっといろいろ試してみたいですね

## 余談: ダッシュボードからのアカウント削除

作成したアカウントを削除する場合は、supabaseの`Authentication`で削除操作をしても消すことはできません。
`table Editor`から`profiles`の中にあるアカウントの情報を削除して、初めて削除できます。