Supabase Authの実装方法(ssrバージョン)

# はじめに
Supabaseのssrパッケージを使ったログイン実装をした記事が少なかったので、共有していきます。


:::note info
今回はemailのconfirmationは実装していません。無料枠だと3通だけしかconfirmationメールができないらしいので、試しにやりたい方は公式ドキュメントを参考にしてみてください。
:::


```: 開発環境
言語：
Typescript

フレームワーク：
Next.js 14(appルーター使用)

DB:
Supabase
```

# ログイン機能の実装
### supabaseの設定変更
まず、ログイン実装に入る前に、supabase画面のAuthenticationページに行き、Providersページを開いてEmailの設定を「enable」に変更してください。変更するために、画像のように「Enable Email Provider」をオンにし、右下に表示されている「Save」ボタンを押してください。

![スクリーンショット 2024-11-03 17.58.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825671/867fe353-c9ff-443a-7542-efd497b41757.png)


supabaseの公式ドキュメントを見ると非常に丁寧な説明がありますのでまずはそちらに沿って実装を行なっていきましょう。

https://supabase.com/docs/guides/auth/server-side/nextjs

### インストール
```
npm install @supabase/supabase-js @supabase/ssr
```

env.ファイルに以下の環境変数を設定してください。
ProjectSetting > API をクリックするとURLとANON Keyが取得できます。
```
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>
```
app配下かルート配下のどちらでもいいですが、utils/supabase/client.tsとutils/supabase/server.tsファイルを作成し、以下のコードをファイルにコピペしてください。

# サーバーサイド実装

```ts:utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
```

```ts:utils/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

```

ルートディレクトリにmiddleware.tsファイルを作成し、以下のようにコードを設定してください。
```ts:middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

```

```ts: utils/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  // IMPORTANT: Avoid writing any logic between createServerClient and
  // supabase.auth.getUser(). A simple mistake could make it very hard to debug
  // issues with users being randomly logged out.

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

  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    // no user, potentially respond by redirecting the user to the login page
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  
}

```

# クライアントサイドの実装
app配下に以下のファイルを作成
app/login/page.tsx
app/login/actions.ts
app/error/page.tsx

```ts:app/login/page.tsx
import { login, signup } from './actions'

export default function LoginPage() {
  return (
    <form>
      <label htmlFor="email">Email:</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="password">Password:</label>
      <input id="password" name="password" type="password" required />
      <button formAction={login}>Log in</button>
      <button formAction={signup}>Sign up</button>
    </form>
  )
}

```

```ts:app/login/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

import { createClient } from '@/utils/supabase/server'

export async function login(formData: FormData) {
  const supabase = await createClient()

  // type-casting here for convenience
  // in practice, you should validate your inputs
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signInWithPassword(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}

export async function signup(formData: FormData) {
  const supabase = await createClient()

  // type-casting here for convenience
  // in practice, you should validate your inputs
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signUp(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}

```

```ts: app/error/page.tsx
export default function ErrorPage() {
  return <p>Sorry, something went wrong</p>
}
```

# page.tsxのカスタマイズ
supabaseの公式ドキュメントに記載の通りにすると最低限のログインページを作ることができます。
ただ、それでは個人の作成しているアプリには物足りかったので、いくつかカスタマイズをしました。
以下にコードを共有します。
```ts: page.tsx
import { Button } from "@/components/ui/button";
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { emailLogin, signup } from "./action";
import { redirect } from "next/navigation";
import { createClient } from "../utils/supabase/server";

export default async function Login({
  searchParams,
}: {
  searchParams: { message: string };
}) {
  const supabase = await createClient();

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

  if (user) {
    return redirect("/");
  }

  return (
    <section className="h-[calc(100vh-57px)] flex justify-center items-center">
      <Card className="mx-auto max-w-sm">
        <CardHeader>
          <CardTitle className="text-2xl">Login</CardTitle>
          <CardDescription>
            Eメールアドレスとパスワードを入力してください。
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          <form id="login-form" className="grid gap-4">
            <div className="grid gap-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                name="email"
                type="email"
                placeholder="m@example.com"
                required
              />
            </div>
            <div className="grid gap-2">
              <div className="flex items-center">
                <Label htmlFor="password">Password</Label>
              </div>
              <Input
                minLength={6}
                name="password"
                id="password"
                type="password"
                required
              />
            </div>
            {searchParams.message && (
              <div className="text-sm font-medium text-destructive">
                {searchParams.message}
              </div>
            )}
            <Button formAction={emailLogin} className="w-full">
              Login
            </Button>
          </form>
          <div className="text-center text-sm">
            "アカウントを持っていないですか？"
            <button formAction={signup} form="login-form" className="underline">
              新規アカウント作成
            </button>
          </div>
        </CardContent>
      </Card>
    </section>
  );
}



```


### パラメータ情報を取得
これはSearchParam引数に入っているmessageプロパティを受け取るようにしています。
エラー時に`redirect('/login?message=Could not authenticate user')`というのをactin.tsに仕込んでおくことで画面側に渡すことができます。

```ts

export default async function Login({
  searchParams,
}: {
  searchParams: { message: string };
})

```

渡されたメッセージは以下の部分で表示するようにしています。
```ts
{searchParams.message && (
   <div className="text-sm font-medium text-destructive">
      {searchParams.message}
   </div>
)}
```

### user情報を取得
以下の部分でユーザー情報を取得して、ログインをしていたらホーム画面に遷移するように設定しています。
```ts
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (user) {
    return redirect("/");
  }
```

そのほかの変更部分はUI等を整えただけなので基本的な部分はチュートリアルとあまり違いはありません。

# おわりに
supabaseのauth設定は時間がかかるかと思いましたが、慣れると5分もかからないくらいで実装ができると思います。
次回はGithub Authの実装をご紹介する予定なので、そちらと併せて参考にしてみてください！

# 参考
本記事で紹介しているコードは以下のチュートリアルを参考にしました。

https://youtu.be/A6-56miVA_0?si=69Sc2BT-VBnLeQF6

https://github.com/jolbol1/supatodo

# JISOUのメンバー募集中！

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
実践的なカリキュラムで、あなたのエンジニアとしてのキャリアを最短で飛躍させましょう！
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp/