今後、Next.jsの認証のスタンダードになるっぽいBetter Authによる認証を実装してみました。
実装したソースコードはこちら
前提条件
prismaが導入されている前提です。
インストール
pnpm add better-auth
Better Auth用の環境変数の準備
BETTER_AUTH_SECRET="(十分長いランダム)" # ハッシュに利用されるシークレット
BETTER_AUTH_URL="http://localhost:3000" # better authのベースURL
NEXT_PUBLIC_APP_URL=http://localhost:3000 # client componentのauthClientで利用
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/MyRealm"
KEYCLOAK_CLIENT_ID="your-client-id"
KEYCLOAK_CLIENT_SECRET="your-client-secret"
Better Authインスタンスの作成
公式手順:
- 3. Better Auth インスタンス作成 - installation | Better Auth
- 4. データベース設定 - installation | Better Auth
- 6. 認証方法の設定 - installation | Better Auth
Auth.jsと異なり、Better AuthではDBが必須で、Better Auth専用のユーザーを管理するためのテーブルを構築する必要があります。
なので、Better AuthインスタンスにはIDプロバイダの情報の他に、DBとの接続設定(今回は Prisma + postgresql)が必要になります。
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { genericOAuth, keycloak } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import prisma from "@/lib/prisma"
export const auth = betterAuth({
// prismaの設定: https://www.better-auth.com/docs/adapters/prisma
database: prismaAdapter(prisma, {
provider: "postgresql"
}),
// プロバイダーヘルパーを利用した設定: https://www.better-auth.com/docs/plugins/generic-oauth#pre-configured-provider-helpers
plugins: [
genericOAuth({
config: [
keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
}),
// https://www.better-auth.com/docs/integrations/next#server-action-cookies
nextCookies(),
],
});
参考:
DBテーブル作成
公式手順:
5: DBテーブル作成 - installation | Better Auth
以下のコマンドを実行し、Prismaのモデル定義 (prisma/schema.prisma) に better-authのテーブル定義を追記します。
pnpm dlx @better-auth/cli@latest generate
Packages: +178
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 178, reused 173, downloaded 5, added 178, done
WARN Issues with peer dependencies found
.
└─┬ @better-auth/cli 1.4.7
└─┬ better-auth 1.4.7
└── ✕ unmet peer drizzle-orm@^0.41.0: found 0.33.0
✔ The file ./prisma/schema.prisma already exists. Do you want to overwrite the schema to the file? … yes
🚀 Schema was overwritten successfully!
追記されるモデル定義
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}
マイグレーション
# マイグレーションファイル生成
pnpm prisma migrate dev --name init --create-only
# マイグレーション実行
pnpm prisma migrate dev
# クライアントの再作成
pnpm prisma generate
こんな感じのテーブルが生成されます
/api/auth/* APIルートの作成
公式手順:
ルートファイルを作成し、Better Authが必要とするAPIルートを生成します。
参考:
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
どのようなAPIルートが生成されるか
残念ながら公式ドキュメントにはどのようなルートが生成されるかの仕様は載っていませんでしたが、betterAuthインスタンス作成時にプラグインとして、 openAPI() を設定するとSwaggerUIのようなAPIドキュメントが見られるようになります。
// ... 省略 ...
import { openAPI } from "better-auth/plugins";
export const auth = betterAuth({
// ... 省略 ...
plugins: [
// ... 省略 ...
openAPI(),
],
});
アプリの起動
pnpm dev
http://localhost:3000/api/auth/reference にブラウザでアクセスするとAPIドキュメントを確認できます。
クライアントインスタンスの作成
公式手順:
8. クライアントインスタンスの作成 - installation | Better Auth
認証サーバーとのやり取りを用意にするためのクライアントライブラリのインスタンスを生成します。
Better Authには、主要なWebフレームワーク向けのクライアントが標準で付属しています。
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
plugins: [genericOAuthClient()],
});
参考:
確認画面の実装
レイアウト
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`} >
<div className="container mx-auto px-4 py-2">
{children}
</div>
</body>
</html>
);
}
トップページ (/)
ログインボタンを配置して /login ページに遷移できるようにします。
import Link from "next/link"
export default function HomePage() {
return (
<main >
<Link href="/login" className="text-blue-600 hover:underline">Login</Link>
</main>
);
}
コンポーネント
ログインページ (/login)
ログインページでは「Sign in with Keycloak」ボタンを表示し、クリックするとKeycloakのログイン画面に飛ぶようにします。
import { LoginButton } from "@/components/ui/LoginButton"
export default function LoginPage() {
return (
<main className="container">
<LoginButton></LoginButton>
</main>
)
}
ログインボタン
'use client';
import { authClient } from "@/lib/auth-client"
export function LoginButton() { // NOTE: client component ではasyncにできない
const signIn = async () => {
const {data, error} = await authClient.signIn.oauth2({
providerId: "keycloak",
callbackURL: "/dashboard", // ログイン後にリダイレクトするURL
})
if (!data || error) {
throw error
}
return data
}
return (
<button className="px-4 py-2 rounded-md border" onClick={signIn} >
Sign in with Keycloak
</button>
)
}
ダッシュボードページ (/dashboard)
ダッシュボードページでは、ログインセッションを表示できるようにします。
サーバーサイドのセッションは src/lib/auth.ts の BetterAuthインスタンスから取得します。 ( auth.api.getSession()
クライアントサイドのセッション取得は src/lib/auth-client のクライアントインスタンスから行います。 (authClient.getSession())
その他ログアウトボタンも作成します。
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/ui/LogoutButton"
import { GetSessionClient } from "@/components/ui/getSessionClient"
export default async function DashboardPage() {
// https://www.better-auth.com/docs/plugins/bearer#5-using-bearer-tokens-outside-the-auth-client
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/login");
}
return (
<main>
<div>
<LogoutButton></LogoutButton>
</div>
<div>
<h2 className="text-2xl">Server</h2>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto">
<code>{JSON.stringify(session, null, 2)}</code>
</pre>
</div>
<div>
<h2 className="text-2xl">Client</h2>
<GetSessionClient></GetSessionClient>
</div>
</main>
);
}
ログアウトボタン
'use client';
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function LogoutButton() { // NOTE: client component ではasyncにできない
const router = useRouter();
const signOut = async () => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/")
}
}
})
}
return (
<button className="px-4 py-2 rounded-md border" onClick={signOut} >
Sign Out
</button>
)
}
クライアントサイドでのセッション取得ボタン
'use client';
import { authClient } from "@/lib/auth-client";
import { useState } from "react"
type Session = Awaited<ReturnType<typeof authClient.getSession>>;
export function GetSessionClient() {
const [session, setSession] = useState<Session|null>(null)
const getSessionClient = async () => {
const session = await authClient.getSession();
setSession(session)
}
return (
<>
<button className="px-4 py-2 rounded-md border" onClick={getSessionClient}>
Get Session
</button>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto">
<code>{JSON.stringify(session, null, 2)}</code>
</pre>
</>
)
}
{
"session": {
"expiresAt": "2025-12-21T15:16:40.824Z",
"token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"createdAt": "2025-12-14T15:16:40.824Z",
"updatedAt": "2025-12-14T15:16:40.824Z",
"ipAddress": "127.0.0.1",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"userId": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7",
"id": "7pyFWec2m10N5PTLBv9Xrkabwzy3aXSY"
},
"user": {
"name": "Keita Midorikawa",
"email": "xxxxxxxxxxxxxxx@example.com",
"emailVerified": true,
"image": null,
"createdAt": "2025-12-14T08:12:03.479Z",
"updatedAt": "2025-12-14T08:12:03.479Z",
"id": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7"
}
}
{
"data": {
"session": {
"expiresAt": "2025-12-21T15:16:40.824Z",
"token": "XXXXXXXXXXXXXXXXXXXXXXXXXX",
"createdAt": "2025-12-14T15:16:40.824Z",
"updatedAt": "2025-12-14T15:16:40.824Z",
"ipAddress": "127.0.0.1",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
"userId": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7",
"id": "7pyFWec2m10N5PTLBv9Xrkabwzy3aXSY"
},
"user": {
"name": "Keita Midorikawa",
"email": "xxxxxxxxxxxxxxx@example.com",
"emailVerified": true,
"image": null,
"createdAt": "2025-12-14T08:12:03.479Z",
"updatedAt": "2025-12-14T08:12:03.479Z",
"id": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7"
}
},
"error": null
}
Proxy(Middleware)によるルートの保護
データベースチェックを含む完全なセッションの検証
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers()
})
const { pathname } = request.nextUrl
const publicPages = ["/", "/login"];
const isPublicPages = publicPages.includes(pathname)
if (!isPublicPages && !session) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Cookieのセッションのみを確認
高速だがセキュリティー面は低い
import {NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export function proxy(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
console.log("session cookie: ", sessionCookie)
const { pathname } = request.nextUrl;
const publicPages = ["/", "/login"];
const isPublicPages = publicPages.includes(pathname)
if (!isPublicPages && !sessionCookie) {
return NextResponse.redirect(new URL("/login", request.url))
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
/dashboard ページのリダイレクト処理を削除
// ... 省略 ...
export default async function DashboardPage() {
// ... 省略 ...
// 削除
// if (!session) {
// redirect("/login");
// }
return (
// ... 省略 ...
);
}
参考情報
cli: https://www.better-auth.com/docs/concepts/cli
keycloak: https://www.better-auth.com/docs/plugins/generic-oauth#pre-configured-provider-helpers
prisma: https://www.better-auth.com/docs/adapters/prisma
next-cookie: https://www.better-auth.com/docs/integrations/next#server-action-cookies
nextjs integrations: https://www.better-auth.com/docs/integrations/next