Step 1 — Better Auth のコア設定とスキーマ生成
src/lib/auth.ts に最小構成を作成します。後でプラグインを追加していくため、最初は素朴な形で書きます。
// src/lib/auth.ts
import { betterAuth } from "better-auth" ;
import { drizzleAdapter } from "better-auth/adapters/drizzle" ;
import { db } from "@/db" ;
export const auth = betterAuth ({
database: drizzleAdapter (db, {
provider: "sqlite" , // PostgreSQL なら "pg"
}),
emailAndPassword: {
enabled: true ,
requireEmailVerification: true ,
minPasswordLength: 12 , // 8 文字はもう推奨しない時代になりました
},
session: {
expiresIn: 60 * 60 * 24 * 7 , // 7日間
updateAge: 60 * 60 * 24 , // 1日ごとにスライドさせる
cookieCache: {
enabled: true ,
maxAge: 60 * 5 , // 5 分間はキャッシュ
},
},
});
export type Session = typeof auth.$Infer.Session;
次に CLI からスキーマを生成します。Antigravity のターミナル機能でそのまま実行できます。
# スキーマファイルを生成(drizzle/schema.ts に出力される)
npx @better-auth/cli generate --output src/db/schema.ts
# マイグレーションファイルを作成
npx drizzle-kit generate
# 適用(D1 の場合)
npx wrangler d1 migrations apply my-saas-db --local
ここで「期待する出力」を一度確認しておきます。schema.ts には users, sessions, accounts, verificationTokens の4テーブルが宣言的に出力されているはずです。もし accounts テーブルだけが欠落している場合、後で OAuth プロバイダを追加する際に外部キー制約エラーが出ます。これは「あとからプラグインを追加したのに generate を再実行していない」ときの典型的なミスで、実際に私も2回ハマりました。プラグインを足したら必ず generate を再実行する、と覚えておいてください。
Step 2 — Next.js への HTTP ハンドラ統合
Better Auth は HTTP ハンドラを直接公開しているので、Next.js の Route Handler に薄いラッパを書くだけで済みます。
// src/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth" ;
import { toNextJsHandler } from "better-auth/next-js" ;
export const { POST , GET } = toNextJsHandler (auth);
たったこれだけです。Auth.js v5 の [...nextauth]/route.ts で何度も悩んだジェネリック地獄が、ここでは存在しません。
クライアント側のクライアントも生成しておきます。
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react" ;
export const authClient = createAuthClient ({
baseURL: process.env. NEXT_PUBLIC_APP_URL ! , // https://example.com
});
export const { signIn , signUp , signOut , useSession } = authClient;
baseURL を NEXT_PUBLIC_APP_URL から取るようにしているのは、本番・ステージング・ローカルで認証エンドポイントが食い違って Cookie が発行されないトラブルを避けるためです。process.env.NEXT_PUBLIC_* は必ずクライアントバンドルに含まれるので、間違えてシークレットを渡さないように注意してください。
Step 3 — メール+パスワード認証 + 確認メールの実装
requireEmailVerification: true を有効にしている以上、確認メール送信のフックを登録する必要があります。私は Resend を使うことが多いので、その例で書きます。
// src/lib/auth.ts (emailAndPassword の続き)
emailAndPassword : {
enabled : true ,
requireEmailVerification : true ,
minPasswordLength : 12 ,
sendVerificationEmail : async ({ user , url }) => {
await resend.emails. send ({
from: "noreply@example.com" ,
to: user.email,
subject: "メールアドレスの確認をお願いします" ,
html: `
<p>${ user . name ?? "お客様"} 様</p>
<p>下記のリンクから、メールアドレスの確認を完了してください。</p>
<p><a href="${ url }">${ url }</a></p>
<p>このリンクの有効期限は 24 時間です。</p>
` ,
});
},
},
この sendVerificationEmail が呼ばれない場合、原因のほぼ 9 割は requireEmailVerification を false のままにしているか、メールプロバイダのドメイン認証が通っていないかのどちらかです。Resend の場合は from のドメインを SPF/DKIM 設定込みで認証しないと、サンドボックス以外には送信できません。
サインアップ画面側のコードはこれだけで動きます。
// src/app/sign-up/page.tsx
"use client" ;
import { signUp } from "@/lib/auth-client" ;
import { useState } from "react" ;
export default function SignUpPage () {
const [ email , setEmail ] = useState ( "" );
const [ password , setPassword ] = useState ( "" );
const [ name , setName ] = useState ( "" );
const handleSubmit = async ( e : React . FormEvent ) => {
e. preventDefault ();
const { data , error } = await signUp. email ({
email, password, name,
callbackURL: "/dashboard" ,
});
if (error) {
alert ( `登録に失敗しました: ${ error . message }` );
return ;
}
alert ( "確認メールを送信しました。メールをご確認ください。" );
};
return (
< form onSubmit = { handleSubmit } >
< input value = { name } onChange = { ( e ) => setName (e.target.value) } placeholder = "お名前" required />
< input type = "email" value = { email } onChange = { ( e ) => setEmail (e.target.value) } placeholder = "メールアドレス" required />
< input type = "password" value = { password } onChange = { ( e ) => setPassword (e.target.value) } placeholder = "パスワード(12文字以上)" minLength = { 12 } required />
< button type = "submit" >登録する</ button >
</ form >
);
}
エラーが返った場合に error.message を直接表示しているのは、Better Auth のエラー文言は基本的にそのままユーザーに見せても問題ない設計だからです。「Email already exists」「Password is too short」など、攻撃者が悪用できないレベルに調整されています。
Step 4 — Google / GitHub OAuth の追加
OAuth プロバイダはオプションを足すだけで動きます。Antigravity のエージェントに「Google と GitHub の OAuth を有効にしてください」と指示すると、おおむねこの形のコードが返ってきます。
// src/lib/auth.ts(socialProviders を追加)
import { betterAuth } from "better-auth" ;
export const auth = betterAuth ({
// ... 既存の設定 ...
socialProviders: {
google: {
clientId: process.env. GOOGLE_CLIENT_ID ! ,
clientSecret: process.env. GOOGLE_CLIENT_SECRET ! ,
},
github: {
clientId: process.env. GITHUB_CLIENT_ID ! ,
clientSecret: process.env. GITHUB_CLIENT_SECRET ! ,
},
},
account: {
accountLinking: {
enabled: true , // 同一メールアドレスで自動リンク
trustedProviders: [ "google" ], // 信頼済みプロバイダのみ自動リンク
},
},
});
クライアント側も signIn.social({ provider: "google" }) を呼ぶだけです。
< button
type = "button"
onClick = { () => signIn. social ({ provider: "google" , callbackURL: "/dashboard" }) }
>
Google でログイン
</ button >
ここで意外と重要なのが accountLinking の設定です。enabled: true にしないと、同じメールアドレスで Google ログインとパスワードログインを使い分けると別ユーザーとして扱われ、「課金履歴が消えた」というクレームに直結します。一方で誰でも自動リンクすると、「メール検証していないプロバイダ」を経由したアカウント乗っ取りリスクが残ります。trustedProviders に Google のように メール検証済みのプロバイダのみ を入れる、という運用が現場では一般的です。
Step 5 — サーバーコンポーネント / ミドルウェアでセッションを取得する
App Router でセッションをサーバー側から取得する場合、Headers から Cookie を読み出す必要があります。Better Auth は auth.api.getSession をそのまま使えるので、ボイラープレートは最小です。
// src/app/dashboard/page.tsx(サーバーコンポーネント)
import { auth } from "@/lib/auth" ;
import { headers } from "next/headers" ;
import { redirect } from "next/navigation" ;
export default async function DashboardPage () {
const session = await auth.api. getSession ({
headers: await headers (),
});
if ( ! session) redirect ( "/sign-in" );
return (
< main >
< h1 >{session.user.name} さんのダッシュボード </ h1 >
</ main >
);
}
ミドルウェアで全体を保護する場合、Edge Runtime で動かすことが多いはずです。Better Auth の cookieCache を有効にしていれば、ミドルウェアは Cookie の HMAC 検証だけで済むため、毎リクエストで DB を叩く必要がありません。
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server" ;
import { getSessionCookie } from "better-auth/cookies" ;
export async function middleware ( request : NextRequest ) {
const sessionCookie = getSessionCookie (request);
const isAuthPage =
request.nextUrl.pathname. startsWith ( "/sign-in" ) ||
request.nextUrl.pathname. startsWith ( "/sign-up" );
if ( ! sessionCookie && ! isAuthPage) {
return NextResponse. redirect ( new URL ( "/sign-in" , request.url));
}
return NextResponse. next ();
}
export const config = {
matcher: [ "/dashboard/:path*" , "/settings/:path*" ],
};
「ミドルウェアで毎回 auth.api.getSession を呼べばいいのでは?」と思う方もいるかもしれませんが、これはおすすめしません。Edge Runtime で D1 や Postgres にアクセスすると、コールドスタート時にまとまったレイテンシが乗ります。ミドルウェアは「ログイン状態らしさ」の判定にとどめ、本物の権限チェックはサーバーコンポーネント側で行う 、という二段構えが本番では安定します。
Step 6 — ロールベースアクセス制御(RBAC)の実装
SaaS を作るなら、admin / member / viewer のような役割分けは早晩必要になります(テナント分離込みの設計は マルチテナント SaaS の RBAC + Stripe 従量課金ガイド も併せて参考にしてください)。Better Auth には admin プラグインがあり、これを使うと数行で導入できます。
// src/lib/auth.ts
import { admin } from "better-auth/plugins" ;
export const auth = betterAuth ({
// ... 既存の設定 ...
plugins: [
admin ({
defaultRole: "member" ,
adminRole: [ "admin" ],
impersonationSessionDuration: 60 * 60 , // 1 時間だけ「なりすまし」可能
}),
],
});
プラグインを追加したら 必ず npx @better-auth/cli generate を再実行してください。users テーブルに role カラムが追加されます。マイグレーションも忘れずに当てます。
権限チェックはサーバー側で次のように書きます。
// src/app/admin/page.tsx
import { auth } from "@/lib/auth" ;
import { headers } from "next/headers" ;
import { redirect } from "next/navigation" ;
export default async function AdminPage () {
const session = await auth.api. getSession ({ headers: await headers () });
if ( ! session) redirect ( "/sign-in" );
if (session.user.role !== "admin" ) redirect ( "/dashboard" ); // 権限不足
return < h1 >管理者ダッシュボード </ h1 > ;
}
クライアントから API 経由で操作する場合は、Better Auth が提供する auth.api.userHasPermission を使うと宣言的に書けます。role 文字列の直接比較は、ロール階層が増えたときに保守性が落ちるので、可能なら早めに userHasPermission 経由に統一しておくのがおすすめです。
Step 7 — Passkey と 2FA を本番品質で乗せる
Passkey(WebAuthn)と TOTP の 2FA は、Better Auth ではプラグインを足すだけで実装できます。
import { passkey } from "better-auth/plugins/passkey" ;
import { twoFactor } from "better-auth/plugins" ;
export const auth = betterAuth ({
plugins: [
passkey ({
rpID: "example.com" ,
rpName: "My SaaS" ,
origin: process.env. NEXT_PUBLIC_APP_URL ! ,
}),
twoFactor ({
issuer: "My SaaS" ,
// バックアップコードは8文字 × 10個を発行する
backupCodes: { amount: 10 , length: 8 },
}),
],
});
クライアント側はこれだけで Passkey の登録ボタンが動きます。
import { authClient } from "@/lib/auth-client" ;
export function RegisterPasskeyButton () {
const handleRegister = async () => {
const { error } = await authClient.passkey. addPasskey ();
if (error) alert ( `登録失敗: ${ error . message }` );
else alert ( "Passkey を登録しました" );
};
return < button onClick = { handleRegister } >このデバイスを Passkey として登録</ button >;
}
Passkey 実装で必ず詰まるポイント は、rpID と origin のミスマッチです。rpID はドメイン名のみ(example.com)、origin はスキーマ込み(https://example.com)。サブドメインで運用するなら rpID を親ドメインにしないとデバイス間で共有できません。私は最初これに 30 分溶かしました。
データベースアダプタの選び方 — スケール段階に合わせて
Better Auth は Drizzle ORM 型安全データベース実装ガイド で詳しく扱った Drizzle / Prisma / Kysely の公式アダプタと、SQLite(D1, libSQL, Bun SQLite)/ PostgreSQL(Neon, Supabase, RDS)/ MySQL・PlanetScale のドライバを提供しています。最初の選択は意外と影響が長く残るので、ここで一度立ち止まる価値があります。
私が個人開発で安定して使っているパターンは、プロトタイプ期は D1 + Drizzle 、有料ユーザーが100人を超えたら Neon Postgres + Drizzle に移行 、というものです。理由は主にコストの予測しやすさで、D1 はセッションがホットになると行読み課金が予想を超えてくる一方、Neon のコンピュートはオートスケールが滑らかで読みやすいからです。
スキーマ生成時のアダプタごとの差分はこんな具合です。
// SQLite(D1, libSQL): タイムスタンプは整数
database : drizzleAdapter (db, { provider: "sqlite" })
// createdAt/updatedAt は INTEGER(unix ms)
// PostgreSQL(Neon, Supabase): ネイティブな timestamp with timezone
database : drizzleAdapter (db, { provider: "pg" })
// TIMESTAMP WITH TIME ZONE で生成される
// MySQL(PlanetScale): 外部キーがデフォルトでオフ
database : drizzleAdapter (db, { provider: "mysql" })
// generateSchema: { mysql: { foreignKeys: false } } の指定が必要
PlanetScale を使う場合の落とし穴がきついので強調しておきます。PlanetScale は外部キーをサポートしないので、ジェネレータも外部キー定義を出してはいけません。foreignKeys: false を忘れると、ローカルの MySQL では通るのに PlanetScale のブランチデプロイで失敗するという、地味に時間を溶かすパターンに陥ります。整合性はアプリケーション層で担保する設計に切り替えてください。auth.api.getSession が結合済みのセッション+ユーザーを返してくれるので、呼び出し側のコードはほぼ変わりません。
Prisma を使う場合は、スキーマ生成のフローが Drizzle CLI ではなく prisma db push ベースになります。Antigravity への指示を「プラグイン追加後にスキーマ再生成」だけで済ませず、「スキーマ再生成 → prisma db push → マイグレーションコミット」と明記するのがコツです。エージェントは2 番目を素で忘れることがあるので、AGENTS.md に明文化しておきましょう。
Auth.js v5 から Better Auth への移行チェックリスト
ゼロから作る場合よりも重要なのが、すでに動いている Auth.js v5 プロジェクトの移行手順です。作業自体は機械的ですが、不可逆な決定が 5 つあるので、コードに手をつける前に必ず意思決定しておきましょう。
決定 1 — ユーザーに再ログインを強いるか
Auth.js v5 は JWT もしくはデータベースセッション、Better Auth は独自の Cookie 形式を使います。互換レイヤは存在しません。事実として「全ユーザーが切り替え時に再ログインする」のは避けられません。メンテナンスウィンドウを切り、事前告知し、切替後 1〜2 日のサポート問い合わせ増は織り込んでください。
決定 2 — ユーザーテーブルを引き継ぐか
users テーブルの構造は微妙に違います。特に emailVerified は、Auth.js では日付型、Better Auth では真偽値です。下流クエリで日付セマンティクスに依存している場合、emailVerified IS NOT NULL を真偽値に変換するワンタイムマイグレーションが必要になります。
// migration/auth-js-to-better-auth.ts
import { db } from "@/db" ;
import { sql } from "drizzle-orm" ;
await db. execute ( sql `
ALTER TABLE users
ADD COLUMN email_verified_bool BOOLEAN NOT NULL DEFAULT FALSE
` );
await db. execute ( sql `
UPDATE users
SET email_verified_bool = (email_verified IS NOT NULL)
` );
await db. execute ( sql `
ALTER TABLE users DROP COLUMN email_verified;
ALTER TABLE users RENAME COLUMN email_verified_bool TO email_verified;
` );
このマイグレーションは「列追加 → バックフィル → 旧列削除」と 3 段階に分けるのがおすすめです。各段階でスナップショットを取って検証してから次に進めるので、本番事故を防げます。
決定 3 — OAuth アカウントリンクをどう扱うか
Auth.js v5 の accounts テーブルと Better Auth のそれは似ていますが、providerAccountId の格納方法が一部のプロバイダで異なります。特に Google の sub と id の扱いは要注意です。既存 OAuth ユーザーがいる場合、accounts テーブルを走査して Better Auth 用にキーを振り直すワンタイムスクリプトを準備してください。
決定 4 — セッションデータの置き場所
Auth.js の JWT セッションはすべてを Cookie に詰めますが、Better Auth はセッションを DB に置き、Cookie には不透明なセッション ID だけを入れます。これは一般にセキュリティ向上(サーバー側で失効可能)ですが、ログインごとに DB アクセスが 1 回増えるトレードオフです。Step 1 で示した cookieCache を有効にすれば、通常の読み取りでは事実上ゼロコストに戻せます。
決定 5 — カスタムコールバックを書き換える覚悟があるか
Auth.js の callbacks.session / callbacks.jwt でカスタムクレームを足していたコードは、Better Auth では additionalFields と明示的なフックに置き換わります。「形を宣言してフックを書く」モデルへの頭の切り替えに 1 週間ほどかかると思います。
私が実際に動いている SaaS で移行した時は、約 3,000 行の認証関連コードに対して 8 時間ほどでした。コーディングよりも Google/GitHub OAuth の実アカウント検証に時間が割かれたので、移行日のスケジュールは「テスト時間」として確保しておくのが良いです。
観測性 — 認証が壊れたことをユーザーより先に知る
本番で認証が壊れる時は静かです。サインアップで 500 を踏む → リロードで画面が戻る → ユーザーが去る、という流れはエラートラッカーには届きません。Better Auth はこのためにフックを提供しているので、ローンチ前に必ず仕込んでおきましょう。
// src/lib/auth.ts に hooks を追加
import { betterAuth } from "better-auth" ;
import { logger } from "@/lib/logger" ;
export const auth = betterAuth ({
// ... 既存設定 ...
hooks: {
after: [
{
matcher : ( context ) => context.path === "/sign-in/email" ,
handler : async ( context ) => {
const { user } = context.context.session ?? {};
logger. info ( "auth.signin.email" , {
userId: user?.id,
success: ! context.context.responseHeaders. get ( "x-error" ),
ip: context.request.headers. get ( "cf-connecting-ip" ),
userAgent: context.request.headers. get ( "user-agent" ),
});
},
},
],
},
});
Day 1 から取っておきたいシグナルは 4 つだけ — サインアップの成否、サインインの成否、パスワードリセット、OAuth プロバイダのエラー。この 4 ストリームを Grafana / Axiom などの単一ダッシュボードに流すと、「認証が健康かどうか」が一目で分かるようになります。Google OAuth のクレデンシャル更新で本番が壊れる事故も、30 分ではなく 30 秒で気づけるようになります。
パスワード値は、ハッシュ済みであっても、絶対にログに出さないこと。Better Auth はフックにパスワードを露出しませんが、独自プラグインを書くときは console.log(context) がリクエストボディを丸ごとシリアライズしないか、必ず確認してください。
実プロダクションで効いた Antigravity プロンプト3選
ここまでお読みいただいた方は、Antigravity のエージェントを「認証実装担当」として使う前提で来ているはずです。私が実際にスニペットファイルに保存して使っている、貼って使えるプロンプトを 3 つ共有します。
プロンプト1 — 新規 Next.js プロジェクトでの初期セットアップ:
このプロジェクトに Better Auth をセットアップしてください。Drizzle ORM を使い、src/db/index.ts の既存 D1 データベースに接続します。メール+パスワードで、確認メール必須、最低12文字。Google と GitHub の OAuth プロバイダを追加し、アカウントリンクは Google のみ自動有効。スキーマ生成、マイグレーションファイル作成まで行い、AGENTS.md に Better Auth の設定方針を追記してください。
プロンプト2 — RBAC を後付けする:
Better Auth の admin プラグインを追加し、3つのロール(admin / member / viewer)を定義します。新規ユーザーのデフォルトは "member"。スキーマを再生成してマイグレーションを作成してください。その後、src/lib/auth-helpers.ts にサーバ専用の requireRole ヘルパーを作り、auth.api.getSession をラップしてロール不一致時に例外を投げる仕様にしてください。/admin 配下のルートを保護します。
プロンプト3 — Auth.js v5 からの移行(最重要):
現在の src/lib/auth.ts と src/app/api/auth/[...nextauth]/route.ts の Auth.js v5 設定を監査し、Markdown で移行プランを出してください。含める項目: (1) データモデルの変更点 (2) Google・GitHub プロバイダごとの移行手順 (3) カスタムコールバックを additionalFields またはフックに置き換える対象一覧 (4) 工数見積もり(時間単位)。コードはまだ書かないでください。プランだけ欲しいです。
3 つ目が一番価値があると感じています。冒頭で「移行は見積もりフェーズの情報密度が一番高い」と書きましたが、まさにそれをエージェントに委ねるためのプロンプトです。移行プロジェクトに着手する前に、必ずこのプロンプトを 1 回回しています。
実測値 — 負荷をかけた時の体感
本記事執筆時点で、私の趣味 SaaS(Cloudflare Workers + D1)に対して取ったロードテストの結果を共有します。条件: 同時 1,000 ユーザーが有効セッション Cookie 付きで /dashboard を叩く / 5 分間。
cookieCache なし : p50 87 ms、p99 412 ms。D1 のリードがボトルネック。
cookieCache あり(5分 TTL) : p50 14 ms、p99 89 ms。リクエストあたりの CPU 時間が 28 ms → 4 ms に低下。
cookieCache + ミドルウェアで HMAC 検証のみ : p50 8 ms、p99 41 ms。キャッシュミス以外で DB はほぼアイドル。
要点としては、ミドルウェアで HMAC 検証だけにすると getSession 呼び出しに比べて約 10 倍速い ということ。99% のリクエストでは DB は要らないので、フルセッションが要るページや API ルートでだけ getSession を呼ぶ設計が、本番で素直に効きます。
同条件の Auth.js v5(JWT モード)は p50 22 ms / p99 124 ms。キャッシュなしの Better Auth より速く、キャッシュありの Better Auth より遅い、という位置取りです。JWT は DB を触らないので速いですが、サーバー側の失効ができないトレードオフがあります。Better Auth + cookieCache の組み合わせが、私の計測ではいいとこ取りだった 、というのが結論です。
Worker バンドルのメモリ使用量は、Auth.js v5 + アダプタが約 1.8 MB に対して、Better Auth + Drizzle アダプタが約 0.9 MB。Cloudflare の Worker 10 MB ハードリミットが、Stripe / AI 連携 / 観測ツールを足していくと意外と早く近づいてきます。認証層のフットプリントを半分にできる効果は、本当に売上に効く部分のヘッドルームを買うことに直結します。
本番運用で詰まる5つの落とし穴と回避法
ここからは、私自身が Better Auth を 4 つのプロジェクトで使ってきて、繰り返し遭遇した本番運用上の落とし穴をまとめます。Antigravity のエージェントだけに任せていると、これらは「動くが安全ではない」コードとして通り抜けてしまいがちなので、人間の目で必ずチェックしてください。
落とし穴 1 — secret を環境変数で渡し忘れる
betterAuth({ secret: process.env.BETTER_AUTH_SECRET }) を明示しないと、開発時に自動生成された秘密鍵が本番に持ち込まれ、デプロイのたびにセッションが無効化されます。openssl rand -hex 32 で 32 バイトのランダム値を作り、BETTER_AUTH_SECRET として必ず明示的に渡してください。
落とし穴 2 — Edge Runtime で cookieCache を有効にしない
ミドルウェアやエッジ関数を使うなら cookieCache.enabled: true は事実上必須です。これを有効にしないと、毎リクエストで DB アクセスが走り、Cloudflare Workers の 50ms CPU 制限に簡単に引っかかります。
落とし穴 3 — マルチテナントで organizationId を session に含めていない
SaaS でテナント分離が必要な場合、Better Auth の organization プラグインを使うか、自前で additionalFields を使ってセッションに organizationId を持たせる必要があります。これを忘れると、同じユーザーがテナント A のセッションでテナント B のデータを見られる、というクリティカルな脆弱性に直結します。
落とし穴 4 — signOut 後に React Query / SWR キャッシュが残る
クライアント側のキャッシュが残ったままだと、ログアウト直後に「次のユーザーが前のユーザーのデータを一瞬見る」事故が起きます。signOut に成功したら queryClient.clear() などで明示的にキャッシュをクリアする習慣をつけてください。
落とし穴 5 — マイグレーションを当て忘れて本番で 500
プラグインを追加 → スキーマ再生成 → ローカルでは動く、しかし本番でマイグレーションを当て忘れて 500、というパターンです。drizzle-kit migrate を CI のデプロイステップに組み込み、ローカル / プレビュー / 本番で同じスキーマが当たっていることを保証してください。
全体を振り返って — 最初の 1 ステップ
ここまで Better Auth の基本から本番運用まで通しでお伝えしてきました。盛り込んだ範囲は広いですが、いきなり全部を入れる必要はありません。
明日からの一歩として、まずは「現在のプロジェクトの auth.ts を 1 ファイル開いて、Better Auth で書き直したらどうなるか」だけを Antigravity のエージェントに見積もらせてみてください。これだけで、Auth.js から Better Auth へ移ることで本当に消える行数と、新たに増える設定が、自分のプロジェクトの肌感覚として掴めます。Auth.js から Better Auth への移行は、コードを書き換える前の「見積もり」が一番情報密度が高い フェーズです。エージェントに任せられるのもまさにそこなので、是非その一歩を試してみてほしいと思います。
認証はサービスの安全性に直結する領域です。動くだけで満足せず、上記 5 つの落とし穴を必ず1つずつ確認しながら、自分のプロダクトに合った形で育てていきましょう。Better Auth は「育てやすい認証基盤」であることが、私が乗り換えを決めた最大の理由でもあります。