Better Auth:Next.js向け認証ライブラリ完全ガイド


Better Auth:Next.js向け認証ライブラリ完全ガイド

認証システムの実装は、Webアプリケーション開発において最も重要かつ複雑な部分の一つです。セキュリティを確保しつつ、優れたユーザー体験を提供する必要があります。

Better Authは、Next.jsとの統合に特化した新しい認証ライブラリで、型安全性と開発者体験を重視した設計が特徴です。この記事では、Better Authの基本から実践的な使い方まで、詳しく解説していきます。

Better Authとは

Better Authは、Next.jsアプリケーション向けに設計された認証ライブラリです。主な特徴は以下の通りです。

主な特徴

  • TypeScriptファースト: 完全な型安全性
  • 柔軟なデータベース対応: Prisma、Drizzle、Kyselyなど
  • 多様な認証方法: OAuth、メール/パスワード、マジックリンク
  • セッション管理: クッキーベースとJWT両方対応
  • ミドルウェア統合: Next.jsミドルウェアとシームレス連携
  • 拡張性: プラグインシステム
  • エッジ対応: Cloudflare Workers、Vercel Edge Functionsで動作

競合との比較

Better Auth vs NextAuth.js (Auth.js)

  • Better Authはより新しく、型安全性が高い
  • NextAuth.jsはエコシステムが成熟
  • Better Authはエッジランタイム対応が優れている

Better Auth vs Clerk

  • Clerkは完全ホスト型、Better Authはセルフホスト
  • Clerkは有料、Better Authは無料(オープンソース)
  • Better Authはデータベースを完全制御可能

Better Auth vs Lucia

  • 両方とも型安全を重視
  • Better Authはより高レベルなAPI
  • Luciaはよりプリミティブで柔軟

セットアップ

インストール

まずは必要なパッケージをインストールします。

npm install better-auth
# または
pnpm add better-auth

データベースのセットアップ

Better Authは様々なORMをサポートしています。ここではPrismaを使った例を示します。

npm install prisma @prisma/client
npx prisma init

Prismaスキーマ

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  emailVerified DateTime?
  name          String?
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts      Account[]
  sessions      Session[]
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?

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

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

マイグレーションを実行します。

npx prisma migrate dev --name init

Better Authの設定

// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma),
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
});

export type Auth = typeof auth;

環境変数

.env.local に以下を追加します。

DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# Better Auth
BETTER_AUTH_SECRET="your-secret-key-min-32-chars"
BETTER_AUTH_URL="http://localhost:3000"

# GitHub OAuth
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"

# Google OAuth
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

API Routeの作成

Next.js App Router

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";

export const { GET, POST } = auth.handler;

このシンプルな設定で、以下のエンドポイントが自動的に作成されます。

  • /api/auth/signin
  • /api/auth/signup
  • /api/auth/signout
  • /api/auth/session
  • /api/auth/callback/github
  • /api/auth/callback/google

Pages Router

// pages/api/auth/[...all].ts
import { auth } from "@/lib/auth";

export default auth.handler;

クライアントの設定

クライアント作成

// lib/auth-client.ts
import { createAuthClient } from "better-auth/client";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

Reactフック

// hooks/use-auth.ts
import { useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";

export function useAuth() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    authClient.getSession().then((session) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });
  }, []);

  return {
    user,
    loading,
    signIn: authClient.signIn,
    signUp: authClient.signUp,
    signOut: authClient.signOut,
  };
}

認証フロー

メール/パスワード認証

サインアップフォーム

// components/signup-form.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export function SignUpForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");

    try {
      const result = await authClient.signUp.email({
        email,
        password,
        name,
      });

      if (result.error) {
        setError(result.error.message);
        return;
      }

      router.push("/dashboard");
    } catch (err) {
      setError("An error occurred during sign up");
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
          minLength={8}
        />
      </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}

      <button
        type="submit"
        className="w-full bg-blue-600 text-white rounded-md p-2 hover:bg-blue-700"
      >
        Sign Up
      </button>
    </form>
  );
}

サインインフォーム

// components/signin-form.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export function SignInForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");

    try {
      const result = await authClient.signIn.email({
        email,
        password,
      });

      if (result.error) {
        setError(result.error.message);
        return;
      }

      router.push("/dashboard");
    } catch (err) {
      setError("An error occurred during sign in");
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}

      <button
        type="submit"
        className="w-full bg-blue-600 text-white rounded-md p-2 hover:bg-blue-700"
      >
        Sign In
      </button>
    </form>
  );
}

OAuth認証

GitHub/Googleログインボタン

// components/oauth-buttons.tsx
"use client";

import { authClient } from "@/lib/auth-client";

export function OAuthButtons() {
  const handleGitHubSignIn = async () => {
    await authClient.signIn.social({
      provider: "github",
      callbackURL: "/dashboard",
    });
  };

  const handleGoogleSignIn = async () => {
    await authClient.signIn.social({
      provider: "google",
      callbackURL: "/dashboard",
    });
  };

  return (
    <div className="space-y-3">
      <button
        onClick={handleGitHubSignIn}
        className="w-full bg-gray-900 text-white rounded-md p-2 hover:bg-gray-800 flex items-center justify-center gap-2"
      >
        <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
          <path
            fillRule="evenodd"
            d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
            clipRule="evenodd"
          />
        </svg>
        Continue with GitHub
      </button>

      <button
        onClick={handleGoogleSignIn}
        className="w-full bg-white text-gray-900 border rounded-md p-2 hover:bg-gray-50 flex items-center justify-center gap-2"
      >
        <svg className="w-5 h-5" viewBox="0 0 24 24">
          <path
            fill="#4285F4"
            d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
          />
          <path
            fill="#34A853"
            d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
          />
          <path
            fill="#FBBC05"
            d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
          />
          <path
            fill="#EA4335"
            d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
          />
        </svg>
        Continue with Google
      </button>
    </div>
  );
}

マジックリンク認証

メールで送られるリンクをクリックするだけでログインできる方式です。

// lib/auth.ts に追加
export const auth = betterAuth({
  // ... 既存の設定
  emailVerification: {
    enabled: true,
    sendVerificationEmail: async ({ user, url }) => {
      // メール送信処理
      await sendEmail({
        to: user.email,
        subject: "Verify your email",
        html: `Click <a href="${url}">here</a> to verify your email.`,
      });
    },
  },
});

クライアント側:

// components/magic-link-form.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";

export function MagicLinkForm() {
  const [email, setEmail] = useState("");
  const [sent, setSent] = useState(false);
  const [error, setError] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");

    try {
      const result = await authClient.signIn.magicLink({
        email,
        callbackURL: "/dashboard",
      });

      if (result.error) {
        setError(result.error.message);
        return;
      }

      setSent(true);
    } catch (err) {
      setError("An error occurred");
    }
  };

  if (sent) {
    return (
      <div className="text-center">
        <p className="text-green-600">
          Check your email for a sign-in link!
        </p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border p-2"
          required
        />
      </div>

      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}

      <button
        type="submit"
        className="w-full bg-blue-600 text-white rounded-md p-2 hover:bg-blue-700"
      >
        Send Magic Link
      </button>
    </form>
  );
}

セッション管理

サーバーサイドでのセッション取得

// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth.api.getSession({
    headers: await cookies(),
  });

  if (!session) {
    redirect("/signin");
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user.name}!</p>
      <p>Email: {session.user.email}</p>
    </div>
  );
}

クライアントサイドでのセッション取得

// components/user-menu.tsx
"use client";

import { useAuth } from "@/hooks/use-auth";

export function UserMenu() {
  const { user, loading, signOut } = useAuth();

  if (loading) {
    return <div>Loading...</div>;
  }

  if (!user) {
    return (
      <a href="/signin" className="text-blue-600 hover:underline">
        Sign In
      </a>
    );
  }

  return (
    <div className="flex items-center gap-4">
      <span>{user.name}</span>
      <button
        onClick={() => signOut()}
        className="text-red-600 hover:underline"
      >
        Sign Out
      </button>
    </div>
  );
}

ミドルウェアで保護

ミドルウェアの設定

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/lib/auth";

export async function middleware(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  const isAuthPage = request.nextUrl.pathname.startsWith("/signin") ||
                     request.nextUrl.pathname.startsWith("/signup");

  if (!session && !isAuthPage) {
    // 未認証の場合はログインページへ
    return NextResponse.redirect(new URL("/signin", request.url));
  }

  if (session && isAuthPage) {
    // 認証済みの場合はダッシュボードへ
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    "/dashboard/:path*",
    "/profile/:path*",
    "/settings/:path*",
    "/signin",
    "/signup",
  ],
};

役割ベースのアクセス制御

// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma),
  // ... 既存の設定
  user: {
    additionalFields: {
      role: {
        type: "string",
        defaultValue: "user",
      },
    },
  },
});

Prismaスキーマに追加:

model User {
  // ... 既存のフィールド
  role String @default("user") // "user" | "admin" | "moderator"
}

ミドルウェアで役割をチェック:

// middleware.ts
export async function middleware(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  const isAdminRoute = request.nextUrl.pathname.startsWith("/admin");

  if (isAdminRoute) {
    if (!session) {
      return NextResponse.redirect(new URL("/signin", request.url));
    }

    if (session.user.role !== "admin") {
      return NextResponse.redirect(new URL("/dashboard", request.url));
    }
  }

  return NextResponse.next();
}

プラグインシステム

Better Authは拡張可能なプラグインシステムを持っています。

2要素認証プラグイン

// lib/auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  // ... 既存の設定
  plugins: [
    twoFactor({
      issuer: "MyApp",
    }),
  ],
});

クライアント側:

// components/two-factor-setup.tsx
"use client";

import { authClient } from "@/lib/auth-client";
import { useState } from "react";
import QRCode from "qrcode.react";

export function TwoFactorSetup() {
  const [qrCode, setQrCode] = useState("");
  const [verificationCode, setVerificationCode] = useState("");

  const enableTwoFactor = async () => {
    const result = await authClient.twoFactor.enable();
    if (result.data) {
      setQrCode(result.data.qrCode);
    }
  };

  const verifyAndActivate = async () => {
    await authClient.twoFactor.verify({
      code: verificationCode,
    });
    alert("Two-factor authentication enabled!");
  };

  return (
    <div className="space-y-4">
      {!qrCode ? (
        <button
          onClick={enableTwoFactor}
          className="bg-blue-600 text-white px-4 py-2 rounded"
        >
          Enable 2FA
        </button>
      ) : (
        <>
          <QRCode value={qrCode} />
          <input
            type="text"
            value={verificationCode}
            onChange={(e) => setVerificationCode(e.target.value)}
            placeholder="Enter code from authenticator app"
            className="border p-2 rounded"
          />
          <button
            onClick={verifyAndActivate}
            className="bg-green-600 text-white px-4 py-2 rounded"
          >
            Verify & Activate
          </button>
        </>
      )}
    </div>
  );
}

ベストプラクティス

1. 環境変数の管理

// lib/env.ts
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string(),
  BETTER_AUTH_SECRET: z.string().min(32),
  BETTER_AUTH_URL: z.string().url(),
  GITHUB_CLIENT_ID: z.string().optional(),
  GITHUB_CLIENT_SECRET: z.string().optional(),
  GOOGLE_CLIENT_ID: z.string().optional(),
  GOOGLE_CLIENT_SECRET: z.string().optional(),
});

export const env = envSchema.parse(process.env);

2. セッションの型安全性

// types/auth.ts
import { auth } from "@/lib/auth";

export type Session = Awaited<
  ReturnType<typeof auth.api.getSession>
>;

export type User = NonNullable<Session>["user"];

使用例:

function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

3. エラーハンドリング

// utils/auth-error.ts
export function handleAuthError(error: unknown) {
  if (error instanceof Error) {
    if (error.message.includes("credentials")) {
      return "Invalid email or password";
    }
    if (error.message.includes("exists")) {
      return "An account with this email already exists";
    }
  }
  return "An unexpected error occurred";
}

使用例:

try {
  await authClient.signUp.email({ email, password, name });
} catch (error) {
  const message = handleAuthError(error);
  setError(message);
}

4. セッションの永続化

// lib/auth.ts
export const auth = betterAuth({
  // ... 既存の設定
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7日間
    updateAge: 60 * 60 * 24, // 24時間ごとに更新
  },
});

トラブルシューティング

よくある問題

1. セッションが取得できない

環境変数が正しく設定されているか確認:

echo $BETTER_AUTH_SECRET
echo $BETTER_AUTH_URL

2. OAuthが動作しない

コールバックURLが正しく設定されているか確認:

GitHub: http://localhost:3000/api/auth/callback/github
Google: http://localhost:3000/api/auth/callback/google

3. データベース接続エラー

Prismaクライアントが最新か確認:

npx prisma generate
npx prisma migrate dev

まとめ

Better Authは、Next.jsアプリケーションに最適な認証ライブラリです。主な利点は以下の通りです。

  • 型安全性: TypeScriptファーストな設計
  • 柔軟性: 様々な認証方法をサポート
  • 拡張性: プラグインで機能追加が容易
  • パフォーマンス: エッジランタイム対応

セルフホストでデータを完全に制御しつつ、優れた開発者体験を提供します。Next.jsで認証システムを構築する際は、Better Authを検討してみてください。