🦾

better-authを使えば、柔軟に認証テーブルを設計できる!【NextJs・Prisma・better-auth】

に公開

はじめに

better-authは、フレームワークに依存せず、さまざまなwebアプリケーションに認証機能を簡単に実装できるライブラリです。

今回、Next.js + Prisma で業務用アプリケーションを開発する機会がありました。認証機能はAuth.js(NextAuth)で実装しようと思っていたのですが、2025年9月にAuth.jsがbetter-authに統合される方針であると聞き、今回はbetter-authを導入してみることにしました。

今回の実装の詳細

  1. インストール
npm install better-auth
  1. Prismaスキーマの準備 & 基本設定
    better-auth では、ユーザー・セッション・アカウントなどの情報を保存するために、専用のテーブルが必要になります。

公式ドキュメント通りに準備すると以下のようなテーブルが必要になります。

model User {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  name          String
  email         String  @unique
  emailVerified Boolean @map("email_verified")
  image         String?

  sessions Session[]
  accounts Account[]

  @@unique([email])
  @@map("users")
}

model Session {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  userId    String   @map("user_id")
  token     String
  expiresAt DateTime @map("expires_at")
  ipAddress String?  @map("ip_address")
  userAgent String?  @map("user_agent")

  user User @relation(fields: [userId], references: [id])

  @@unique([token])
  @@map("sessions")
}

model Account {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  userId                 String    @map("user_id")
  accountId              String    @map("account_id")
  providerId             String    @map("provider_id")
  accessToken            String?   @map("access_token")
  refreshToken           String?   @map("refresh_token")
  accessTokenExpiresAt   DateTime? @map("access_token_expires_at")
  refreshTokenExpiresAt  DateTime? @map("refresh_token_expires_at")
  scope                  String?
  idToken                String?   @map("id_token")
  password               String? 

  user User @relation(fields: [userId], references: [id])

  @@map("accounts")
}

model Verification {
  id         String   @id @default(cuid())
  createdAt  DateTime @default(now()) @map("created_at")
  updatedAt  DateTime @updatedAt @map("updated_at")

  identifier String
  value      String
  expiresAt  DateTime @map("expires_at")

  @@map("verifications")
}

公式ドキュメントでは User / Session という名前が使われていますが、
今回は 店舗スタッフ向けの業務管理アプリ を作っているため、User という名前だと「一般ユーザー(顧客)」と混同しそうでした。

そこで、認証主体がスタッフであることが明確になるよう、テーブル名を以下のようにカスタマイズしました。

// ユーザーテーブル(カスタム名: Staff)
model Staff {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  name          String
  email         String  @unique
  emailVerified Boolean @map("email_verified")
  image         String?

  staffSessions StaffSession[]
  staffAccounts StaffAccount[]

  // テーブル名は "staff" にカスタマイズ
  @@map("staff") 
}

// セッション管理テーブル
model StaffSession {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  staffId   String   @map("staff_id")
  token     String
  expiresAt DateTime @map("expires_at")
  ipAddress String?  @map("ip_address")
  userAgent String?  @map("user_agent")

  staff Staff @relation(fields: [staffId], references: [id])

  @@map("staff_sessions")
}

// アカウント管理テーブル(OAuth、メール/パスワード認証用)
model StaffAccount {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  staffId                String    @map("staff_id")
  accountId              String    @map("account_id")
  providerId             String    @map("provider_id")
  accessToken            String?   @map("access_token")
  refreshToken           String?   @map("refresh_token")
  accessTokenExpiresAt   DateTime? @map("access_token_expires_at")
  refreshTokenExpiresAt  DateTime? @map("refresh_token_expires_at")
  scope                  String?
  idToken                String?   @map("id_token")
  password               String?   @map("password")

  staff Staff @relation(fields: [staffId], references: [id])

  @@map("staff_accounts")
}

// メール認証・パスワードリセット用テーブル
model StaffVerification {
  id         String   @id @default(cuid())
  createdAt  DateTime @default(now()) @map("created_at")
  updatedAt  DateTime @updatedAt @map("updated_at")

  identifier String
  value      String
  expiresAt  DateTime @map("expires_at")

  @@map("staff_verifications")
}

続いて、lib/better-auth/auth.tsを以下のように設定します。

export const auth = betterAuth({
    database: prismaAdapter(prisma, {
        provider: "postgresql",
    }),
    // 今回はemailとpasswordに認証を実装したので、このオプションをtrueにしておきます
    emailAndPassword: {
        enabled: true,
    },
    user: {
        // userに対応するテーブル名を指定
        modelName: "Staff",
    },
    session: {
       // sessionに対応するテーブル名を指定
        modelName: "StaffSession",
        fields: {
            userId: 'staffId' // カラム名のマッピング
        }
    },
    account: {
        // accountに対応するテーブル名を指定
        modelName: "StaffAccount",
        fields: {
            userId: 'staffId' // カラム名のマッピング
        }
    },
    verification: {
        // verificationに対応するテーブル名を指定
        modelName: "StaffVerification",
    },
});

この設定により、

  • Prisma 側では 分かりやすい命名
  • better-auth 側では 内部的に user / session として扱う
    という両立が可能になりました。
  1. 新規登録・ログイン機能の実装

Next.js App Routerを使用している場合、app/api/auth/[...all]/route.tsを作成します。

import { auth } from "@/lib/better-auth/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

たったこれだけで、以下のAPIエンドポイントが自動生成されます。

  • /api/auth/sign-up
  • /api/auth/sign-in
  • /api/auth/sign-out
  • /api/auth/session

lib/better-auth/auth-client.tsを作成します。

import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient()

このauthClientというインスタンスを使えば、簡単に新規登録ページやログインページが実装できます。

新規登録機能ページ↓

"use client"

import { useState } from "react"
import { authClient } from "./auth-client";
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export default function SignUpPage() {
    const [isLoading, setIsLoading] = useState(false)
    
    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        setIsLoading(true)
        
        const formData = new FormData(e.currentTarget)
        const email = formData.get("email") as string
        const password = formData.get("password") as string

        await authClient.signUp.email({
            email,
            password,
            // 新規登録完了後に遷移するページ
            callbackURL: "/"
        }, {
            onSuccess: (ctx) => {
                // 成功時に実行する処理
            },
            onError: (ctx) => {
                alert('エラーが発生しました')
            },
        });
        
        setIsLoading(false)
    }

    return (
        <form onSubmit={handleSubmit}>
            <Input name="name" placeholder="お名前" required />
            <Input name="email" type="email" placeholder="メールアドレス" required />
            <Input name="password" type="password" placeholder="パスワード" required />
            <Button type="submit" disabled={isLoading}>
                {isLoading ? "登録中..." : "登録する"}
            </Button>
        </form>
    )
}

ログインページ↓

"use client"

import { useState } from "react"
import { authClient } from "./auth-client";
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

export default function SignInPage() {
    const [isLoading, setIsLoading] = useState(false)

    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        setIsLoading(true)

        const formData = new FormData(e.currentTarget)
        const email = formData.get("email") as string
        const password = formData.get("password") as string

        await authClient.signIn.email({
            email,
            password,
            // ログイン完了後に遷移するページ
            callbackURL: "/",
            // ブラウザ閉じてもログイン状態を保持するかどうか?
            rememberMe: false
        }, {
            onSuccess: () => {
                // 成功時に実行する処理
            },
            onError: (ctx) => {
                alert(ctx.error.message);
            }
        })
        
        setIsLoading(false)
    }
        
        

    return (
        <form onSubmit={handleSubmit}>
            <Input name="email" type="email" placeholder="メールアドレス" required />
            <Input name="password" type="password" placeholder="パスワード" required />
            <Button type="submit" disabled={isLoading}>
                {isLoading ? "ログイン中..." : "ログイン"}
            </Button>
        </form>
    )
}

セッション情報は以下のように取得できます。

  • クライアント側でhooks形式で取得する場合
"use client"

import { auth } from "@/lib/better-auth/auth"

export default function StaffPage() {
    const { data: session } = authClient.useSession()

    // `user`にはstaffテーブルの情報が入っています。
    const user = session?.user;

    ...(省略)
}
  • サーバー側や非同期関数内で取得する場合
import { authClient } from "@/lib/client"

// awaitとして取得
const { data: session } = await authClient.getSession()
  1. 独自フィールドの追加
    また、今回作ったアプリではStaffにRoleをつけたいという要件がありました。強い権限のRoleと通常権限のRoleを準備するイメージです。

まず、prisma.schemaを以下のように編集して、Staffテーブルにroleカラムを追加します。

enum StaffRole {
  ADMIN // 最も強い権限
  MANAGER // 店長レベルの権限
  MEMBER // 通常スタッフ
}

model Staff {
  ...(省略)

  // スタッフのRoleを表すカラム
  role StaffRole @default(MEMBER)
}

lib/better-auth/auth.tsに以下のように書き加えます。

export const auth = betterAuth({
    ...(省略)
    
    user: {
        modelName: "Staff",
        // `additionalFields`にroleを追加
        additionalFields: {
            role: {
                type: "string",
                required: true,
                defaultValue: StaffRole.MEMBER,
                input: false,
            },
        }
    },
    
    ...(省略)
});

このように設定することで、roleカラムの値にbetter-auth経由で簡単にアクセスできるようになります。

例えば、MANAGERというroleでしかページにアクセスできないようにガードをつける処理は以下のように書けます。

import { auth } from "@/lib/better-auth/auth";
import { StaffRole } from "@/lib/generated/prisma/enums";
import { headers } from "next/headers";

import { redirect } from "next/navigation";

export default async function ManagerAuthenticatedLayout({
    children,
}: Readonly<{
    children: React.ReactNode;
}>) {
    const session = await auth.api.getSession({
        headers: await headers()
    });

    if (!session) {
        redirect("/sign-in");
    }

    // MANGER権限しかアクセスできないようにする
    if(session.user.role !== StaffRole.MANAGER) {
        redirect("/404");
    }

    return (<Page />)
}

better-auth導入で感じたGoodポイント

  1. 設定ファイルの記述が楽
    NextAuthと違い、複雑なコールバック関数の実装やプロバイダー設定が不要です。設定ファイルの準備がとても簡単だと感じました。

  2. 認証用テーブルの準備、カスタムフィールドの準備が行いやすい
    認証関連のテーブルについて、テーブル名やカラム名を自由にカスタムできます。そのため、アプリのコア機能を担うテーブルとの衝突を避けつつ、認証関連テーブルを追加できます。
    additionalFields機能を使えば、カスタムフィールドの追加も簡単です。

  3. 特定のプロバイダーやフレームワークへの依存がないため、動作確認・AI任せの実装が行いやすい
    例えば、FirebaseAuthやSupabaseを導入する際、そのプロバイダー特有の仕様や設計を理解する必要があります。better-authはそのようなことが必要ないので人間・AIの両方にとって理解しやすく、動作確認・バイブコーディングも行いやすいです。

まとめ

better-authを使えば、Next.js + Prismaアプリに認証機能を最短10分で追加できます。
今後、新規に認証機能を実装する際はぜひ使ってみてください!

公式ドキュメント: https://www.better-auth.com/

Discussion