better-authを使えば、柔軟に認証テーブルを設計できる!【NextJs・Prisma・better-auth】
はじめに
better-authは、フレームワークに依存せず、さまざまなwebアプリケーションに認証機能を簡単に実装できるライブラリです。
今回、Next.js + Prisma で業務用アプリケーションを開発する機会がありました。認証機能はAuth.js(NextAuth)で実装しようと思っていたのですが、2025年9月にAuth.jsがbetter-authに統合される方針であると聞き、今回はbetter-authを導入してみることにしました。
今回の実装の詳細
- インストール
npm install better-auth
- 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 として扱う
という両立が可能になりました。
- 新規登録・ログイン機能の実装
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()
- 独自フィールドの追加
また、今回作ったアプリでは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ポイント
-
設定ファイルの記述が楽
NextAuthと違い、複雑なコールバック関数の実装やプロバイダー設定が不要です。設定ファイルの準備がとても簡単だと感じました。 -
認証用テーブルの準備、カスタムフィールドの準備が行いやすい
認証関連のテーブルについて、テーブル名やカラム名を自由にカスタムできます。そのため、アプリのコア機能を担うテーブルとの衝突を避けつつ、認証関連テーブルを追加できます。
additionalFields機能を使えば、カスタムフィールドの追加も簡単です。 -
特定のプロバイダーやフレームワークへの依存がないため、動作確認・AI任せの実装が行いやすい
例えば、FirebaseAuthやSupabaseを導入する際、そのプロバイダー特有の仕様や設計を理解する必要があります。better-authはそのようなことが必要ないので人間・AIの両方にとって理解しやすく、動作確認・バイブコーディングも行いやすいです。
まとめ
better-authを使えば、Next.js + Prismaアプリに認証機能を最短10分で追加できます。
今後、新規に認証機能を実装する際はぜひ使ってみてください!
公式ドキュメント: https://www.better-auth.com/
Discussion