BetterAuthとPrismaで認証機構を実装する

# はじめに
Next.jsで認証機能を実装する際によく聞くライブラリといえばNextAuth.jsやAuth.jsといったものが有名です。今回はBetterAuthというフレームワークに非依存かつプラグインによって簡単にパスキーや二段階認証といったセキュリティ機能を作成できるライブラリを使い、またデータベース接続部分にPrismaを使用して認証機能を実装していきたいと思います。認証プロバイダはDiscordを使用します。Googleなど別のプロバイダを利用する場合は、適宜クライアントと秘密鍵を取得し設定してください。

# Better-Authとは?
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/c1ab2538-fcd8-4460-86ea-e377a921d85d.png)

https://www.better-auth.com/docs/introduction

Better-AuthはTypeScript向けに最適化された、フレームワークに依存せず、簡単に認証機能を実装できる認証ライブラリです。2025/03/10時点での最新バージョンは`v1.2.3`と割と最近のライブラリです。
認証プロバイダとして、メールアドレスとパスワードのほかにAppleやGithub、Google、Microsoftなど数多くのプロバイダを利用できます。また、データベースはMySQL、PostgreSQL、SQLiteに対応しています。
最大の目玉にプラグインがあり、こちらは二段階認証やパスキー、Google One Tap、SSOなどを簡単に追加できるほか、JWT認証やCAPTCHA認証なども追加できます。

# Prismaとは?
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/994eb174-cc48-42e8-8ad9-679c5503b376.png)

https://www.prisma.io/docs/orm

Prismaは、Node.jsでオープンソースで利用できるORM(Object-Relational Mapping)です。
通常データベースとやり取りする場合はSQL文でやり取りする必要があり、大変面倒です。そこで、Prismaを使うことでJavaScript・TypeScriptで記述でき、型安全なデータベースアクセスができるようになります。ちなみにPrismaは多くのデータソースとフレームワークに対応しているためデータベースアクセスするのがかなり楽になります。

# Next.jsのセットアップ
まずはNext.jsのセットアップを行います。Next.jsは最新版を使っていきます。
今回はBunを使っているので`bunx`を使用していますが、npmを使う場合は`npx`に読み替えてください。
```sh:create-next-app
bunx create-next-app
```
```sh:next.js initialize configuration
√ What is your project named? ... next-auth
√ Would you like to use TypeScript? ... No / > Yes
√ Would you like to use ESLint? ... No / > Yes
√ Would you like to use Tailwind CSS? ... No / > Yes
√ Would you like your code inside a `src/` directory? ... No / > Yes
√ Would you like to use App Router? (recommended) ... No / > Yes
√ Would you like to use Turbopack for `next dev`? ... > No / Yes
√ Would you like to customize the import alias (`@/*` by default)? ... > No / Yes
Creating a new Next.js app in xxxxxx.
```
TypeScript、ESLint、Tailwind CSS、srcディレクトリの使用、App Routerの使用はそれぞれYesです。Turbopackとインポートエイリアスの変更は今回行わないのでNoにしています。ここは適宜変更してください。

# Prisma、Better-Authのインストール
PrismaとBetter-Authを依存関係に追加していきます。
```sh:bun
bun add prisma better-auth @prisma/client
```

# Shadcn/uiのインストール
Better-Authのログイン画面にはshadcn/uiが使用されています。手っ取り早く使うことができるので追加しておきます。
```sh:bun
bunx --bun shadcn@latest init
```
:::note warn
`bunx`ではbunのバージョンが古い場合にパッケージ名が重複してpostinstallなどに失敗する場合があります。その場合は`bun upgrade`でbunを最新バージョンにしたのち、`bun pm cache rm`でキャッシュを削除して再度試してみてください。
:::
このようになればOKです。
```
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
√ Which color would you like to use as the base color? » Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating src\app\globals.css
✔ Installing dependencies.
✔ Created 1 file:
  - src\lib\utils.ts

Success! Project initialization completed.
```
また、ログインフォームのためにコンポーネントを追加します。
```
bunx shadcn add
```
コマンドで、コンポーネントを選択してインストールすることができます。インストールが必要なコンポーネントは次の通りです。
- Button
- Card
- Input
- Label
- Checkbox

# Better-Authのセットアップ
`src/lib`に`auth.ts`を作成します。
```ts:auth.ts
import { betterAuth } from "better-auth";
import { PrismaClient } from "@prisma/client";
import { prismaAdapter } from "better-auth/adapters/prisma";

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "mysql",
  }),
  socialProviders: {
    discord: {
      clientId: process.env.DISCORD_CLIENT_ID as string,
      clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
    },
    // ほかのプロバイダを使う場合はここに追加
  },
});
```
今回はDiscordのみの認証で、データベースにMySQLをPrisma経由で通信します。
DiscordのクライアントIDと秘密鍵は、Discordのデベロッパーポータルから新しいアプリケーションを作成してそれぞれ設定してください。

https://discord.com/developers/applications

なお、Prismaを介してデータベースに接続する場合はBetter-Authが提供している [`prismaAdapter`](https://www.better-auth.com/docs/adapters/prisma)を使います。

次に、Next.jsのAPI Routeを作成します。`src/auth/[...all]/route.ts`を作成します。
```ts:src/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
```
最後に、`lib/auth-client.ts`を作成し、クライアント側を実装します。
```ts:lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { toast } from "sonner";

export const client = createAuthClient({
  baseURL: process.env.BETTER_AUTH_URL,
  fetchOptions: {
    onError(e) {
      if (e.error.status === 429) {
        toast.error("Too many requests. Please try again later.");
      }
    },
  },
});

export const { signUp, signIn, signOut, useSession } = client;

client.$store.listen("$sessionSignal", async () => {});
```
# 環境変数の設定
ルートディレクトリに`.env`ファイルを作成します。`.env.local`では後述するPrismaのスキーマファイルから読み取ることができないため`.env`を使用してください。
```.env
MYSQL_DATABASE_URL="mysql://<user>:<password>@<host address>/<db name>?schema=public"
BETTER_AUTH_SECRET=""
BETTER_AUTH_URL="http://localhost:3000"
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
```
Better-Authの秘密鍵は、次のサイトにある「Set Environment Variables」内のSecret Keyの項目にある「Generate Secret」ボタンを押すことで生成されます。OpenSSLで生成することもできます。

https://www.better-auth.com/docs/installation

# ログインフォームの作成
https://www.better-auth.com/

にある「Create Sign in Box」ボタンを押し、使用するプロバイダなどを設定し、フレームワークを選ぶだけでshadcn/uiによるコンポーネントが生成されます。
生成されたコンポーネントを`src/components/sign-in.tsx`に貼り付け、signinページで利用します。以下は生成された例です。
```tsx:src/components/sign-in.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  CardDescription,
  CardFooter,
} from "@/components/ui/card";
import { useState } from "react";
import { Loader2, Key } from "lucide-react";
import { signIn } from "@/lib/auth-client";
import Link from "next/link";
import { cn } from "@/lib/utils";

export default function SignIn() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);

  return (
    <Card className='max-w-md'>
      <CardHeader>
        <CardTitle className='text-lg md:text-xl'>Sign In</CardTitle>
        <CardDescription className='text-xs md:text-sm'>
          Enter your email below to login to your account
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className='grid gap-4'>
          <div
            className={cn(
              "w-full gap-2 flex items-center",
              "justify-between flex-col"
            )}>
            <Button
              variant='outline'
              className={cn("w-full gap-2")}
              onClick={async () => {
                await signIn.social({
                  provider: "discord",
                  // 遷移先を変更する場合はここを変更
                  callbackURL: "/dashboard",
                });
              }}>
              <svg
                xmlns='http://www.w3.org/2000/svg'
                width='1em'
                height='1em'
                viewBox='0 0 24 24'>
                <path
                  fill='currentColor'
                  d='M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12'></path>
              </svg>
              Sign in with Discord
            </Button>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}
```

# サインインページ、コールバックページの作成
先ほど作成したコンポーネントをログインページで使用します。`src/app/login/page.tsx`を作成し、次のコードを貼り付けます。
```tsx:src/app/login/page.tsx
"use client";
//適宜置き換えてください
import SignIn from "@/components/sign-in";

export default function Login() {
  return (
    <div className='w-full'>
      <div className='flex items-center flex-col justify-center w-full md:py-10'>
        <div className='md:w-[400px]'>
          <SignIn />
        </div>
      </div>
    </div>
  );
}
```
ログイン後、`/dashboard`ページに移動するようになっています。そのためdashboardページを作成します。テストとして、ログイン後のデータをjson形式で表示するだけにしてみます。
`src/app/dashboard/page.tsx`を作成し、次のコードを貼り付けます。
```tsx:src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function Dashboard() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });
  if (!session) {
    redirect("/");
  }
  return (
    <div>
      <h1>Dashboard</h1>
      <pre>{JSON.stringify(session, null, 2)}</pre>
    </div>
  );
}
```
これでBetter-Authとフロントエンド側の設定は完了しました! 次にPrisma側の設定をしていきます。

# Prismaのスキーマ生成
```
bunx prisma init
```
でスキーマファイルを作成できます。スキーマファイルは`.prisma/schema.prisma`になっています。生成後のファイルはPostgreSQLを使う設定になっているのでProviderを`mysql`に、.envの参照を`MYSQL_DATABASE_URL`に変更します。
```
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("MYSQL_DATABASE_URL")
}
```
次に、Better-AuthのCLIを用いてテーブルなどの情報を生成します。
```
bunx @better-auth/cli@latest generate
```
でPrisma用のスキーマを自動生成してくれます。スキーマが生成された後はPrismaのクライアントを生成します。
```
bunx prisma generate
```
```
✔ Generated Prisma Client (v6.4.1) to .\node_modules\@prisma\client in 64ms

Start by importing your Prisma Client (See: https://pris.ly/d/importing-client)

Help us improve the Prisma ORM for everyone. Share your feedback in a short 2-min survey: https://pris.ly/orm/survey/release-5-22
```
となれば成功です。

# Prismaのマイグレーション
では最後にPrisma経由で実際にテーブルを作成します。
```
bunx prisma migrate dev --name init
```
```
Applying migration `20250310065802_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20250310065802_init/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (v6.4.1) to .\node_modules\@prisma\client in 66ms
```
となれば成功です。PhpMyAdminなどでテーブルが作成されていることが確認できればデータベースの接続が完了です。
なお、
```
Error: P1010

User `xxxx` was denied access on the database `xxxx`
```
というエラーが出た場合は、そのユーザーに`CREATE, ALTER, DROP, REFERENCES ON *.*`の権限を付与すると成功[^1]します。なお、この方法は開発環境の場合のみ必要です。

https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/shadow-database#shadow-database-user-permissions

本番環境で使う場合は本番環境用のユーザーを作成し、必要最低限の権限を付与したうえで`bunx prisma migrate deploy`を実行する必要があります。

https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production#production-and-testing-environments

実際に、localhost:3000/login にアクセスし、Discordのアカウントでログインするとダッシュボードページにセッション情報がJSON形式で表示されていると思います。

# おわりに
Better-AuthとPrismaを用いることでデータベースを簡単に接続することができました。
なお、この記事は本番環境を想定しておらずあくまで開発環境で実行することを想定しています。そのため本番環境で使用する場合は、Prismaのマイグレーションをデプロイモードにするなどを確認しておくことをおすすめします。
[^1]: https://zenn.dev/tatsuyasusukida/articles/why-prisma-migrate-dev-fails-in-myql#対処方法 を参考にしました