セキュリティ

Next.js + Turso + Drizzle に Better Auth で Google 認証を実装するガイド

Better Auth を使って Next.js 16 アプリケーションに Google OAuth 認証を導入する方法をステップバイステップで解説。Turso データベースと Drizzle ORM との統合も詳しく説明します。

本記事では、Better Auth を使って Next.js 16 アプリケーションに Google OAuth 認証を実装する方法を解説します。データベースには Turso、ORM には Drizzle ORM を使用し、環境変数の管理には t3-env を使用します。

Better Auth とは

Better Auth は、TypeScript 向けのフレームワーク非依存な認証・認可ライブラリです。

主な特徴

  • フレームワーク非依存: Next.js、SvelteKit、Remix など様々なフレームワークに対応
  • TypeScript ファースト: 型安全な API を提供
  • 豊富なプロバイダー: Google、GitHub、Discord など多数の OAuth プロバイダーをサポート
  • プラグインシステム: 2FA、パスキー、マジックリンクなど拡張機能を追加可能
  • Drizzle ORM 対応: ネイティブアダプターで簡単に統合
  • CLI によるスキーマ自動生成: 手動でスキーマを書く必要なし

前提条件

  • Next.js 16 プロジェクトがセットアップ済み
  • Turso データベースと Drizzle ORM が導入済み
  • t3-env が導入済み
  • Google Cloud Console で OAuth 認証情報(Client ID、Client Secret)を取得済み

ステップ1: パッケージのインストール

# npm
npm install better-auth

# pnpm
pnpm add better-auth

# bun
bun add better-auth

ステップ2: 環境変数の設定

.env.local に環境変数を追加

# Better Auth
BETTER_AUTH_SECRET=your-random-secret-key-here
BETTER_AUTH_URL=http://localhost:3000

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

# Turso Database(既存)
TURSO_DATABASE_URL=libsql://your-database.turso.io
TURSO_AUTH_TOKEN=your-auth-token

# サイト URL(既存)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

環境変数の説明

変数名説明
BETTER_AUTH_SECRET暗号化・署名・ハッシュに使用する秘密鍵。32文字以上の高エントロピー文字列が必要。本番環境で未設定の場合はエラーになります。
BETTER_AUTH_URLアプリのベース URL。OAuth コールバック URL の構築に使用されます。この設定がないと redirect_uri_mismatch エラーが発生します。

シークレットキーの生成

BETTER_AUTH_SECRET には安全なランダム文字列を使用します:

# OpenSSL で生成
openssl rand -base64 32

# または Node.js で生成
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

src/env.ts に環境変数のスキーマを追加

t3-env を使用して環境変数を型安全に管理します。既存の src/env.ts に Better Auth 用の環境変数を追加します:

import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  /**
   * サーバーサイド専用の環境変数
   */
  server: {
    // Turso Database
    TURSO_DATABASE_URL: z
      .string()
      .refine((url) => url.startsWith("file:") || url.startsWith("libsql://"), {
        message: "TURSO_DATABASE_URL must start with 'file:' or 'libsql://'",
      }),
    TURSO_AUTH_TOKEN: z.string(),

    // Better Auth
    BETTER_AUTH_SECRET: z.string().min(32, {
      message: "BETTER_AUTH_SECRET must be at least 32 characters",
    }),
    BETTER_AUTH_URL: z.string().url(),

    // Google OAuth
    GOOGLE_CLIENT_ID: z.string().min(1),
    GOOGLE_CLIENT_SECRET: z.string().min(1),
  },

  /**
   * クライアントサイドでも使用可能な環境変数
   * NEXT_PUBLIC_ プレフィックスが必須
   */
  client: {
    NEXT_PUBLIC_SITE_URL: z.string().url(),
  },

  /**
   * Next.js 13.4.4 以降では experimental__runtimeEnv を使用
   * クライアント変数のみ指定すれば OK
   */
  experimental__runtimeEnv: {
    NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
  },

  /**
   * 静的解析ツール(knip など)で環境変数が未設定の場合にバリデーションをスキップ
   */
  skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
});

この設定により、以下のメリットが得られます:

  • 型安全: env.GOOGLE_CLIENT_ID のように型付きでアクセス可能
  • バリデーション: アプリ起動時に環境変数の形式をチェック
  • IDE 補完: 環境変数名の補完が効く

ステップ3: Better Auth の設定

src/lib/auth.ts を作成

t3-env の env オブジェクトを使用して環境変数にアクセスします:

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
import { env } from "@/env";

export const auth = betterAuth({
  // データベース設定
  database: drizzleAdapter(db, {
    provider: "sqlite", // Turso は SQLite 互換
  }),

  // ソーシャルプロバイダー設定
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
});

// 型のエクスポート
export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;

Note: process.env.GOOGLE_CLIENT_ID! のような Non-null assertion を使用する代わりに、t3-env の env オブジェクトを使用することで、環境変数が確実に存在することが保証されます。

ステップ4: スキーマの自動生成

Better Auth CLI を使用して、Drizzle 用のスキーマファイルを自動生成します。

スキーマファイルの生成

npx @better-auth/cli generate

このコマンドを実行すると、以下のようなプロンプトが表示されます:

? Where would you like to save the schema? › src/db/schema/auth.ts

出力先を指定すると、Better Auth が必要とするテーブル定義(usersessionaccountverification)が自動生成されます。

CLI オプション

# 出力先を指定
npx @better-auth/cli generate --output src/db/schema/auth.ts

# 確認プロンプトをスキップ
npx @better-auth/cli generate --yes

# 設定ファイルのパスを指定
npx @better-auth/cli generate --config src/lib/auth.ts

生成されるスキーマの例

CLI により、以下のようなスキーマが自動生成されます:

// src/db/schema/auth.ts(自動生成)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const user = sqliteTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
  image: text("image"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

export const session = sqliteTable("session", {
  id: text("id").primaryKey(),
  expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
  token: text("token").notNull().unique(),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id")
    .notNull()
    .references(() => user.id),
});

export const account = sqliteTable("account", {
  id: text("id").primaryKey(),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: text("user_id")
    .notNull()
    .references(() => user.id),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
  refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
  scope: text("scope"),
  password: text("password"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

export const verification = sqliteTable("verification", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }),
  updatedAt: integer("updated_at", { mode: "timestamp" }),
});

スキーマのエクスポート

生成されたスキーマを src/db/schema/index.ts でエクスポートします:

export * from "./auth";
// 他のスキーマがあればここに追加

ステップ5: データベース接続の更新

src/db/index.ts を更新

t3-env の env オブジェクトを使用します:

import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { env } from "@/env";
import * as schema from "./schema";

const client = createClient({
  url: env.TURSO_DATABASE_URL,
  authToken: env.TURSO_AUTH_TOKEN,
});

export const db = drizzle(client, { schema });

ステップ6: マイグレーションの実行

スキーマ生成後、Drizzle Kit でマイグレーションを実行します:

# マイグレーションファイルを生成
npx drizzle-kit generate

# マイグレーションを実行
npx drizzle-kit migrate

または、package.json にスクリプトが定義されている場合:

npm run db:generate
npm run db:migrate

ステップ7: API ルートの作成

src/app/api/auth/[...all]/route.ts を作成

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

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

この設定により、/api/auth/* パスで認証関連のエンドポイントが自動的に作成されます。

ステップ8: クライアントの設定

src/lib/auth-client.ts を作成

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

// 便利なエイリアスをエクスポート
export const { signIn, signOut, useSession } = authClient;

Note: 認証サーバーとクライアントが同一ドメインで動作している場合、baseURL は省略可能です。異なるドメインで動作している場合は、baseURL: "http://localhost:3000" のように指定してください。

ステップ9: ナビゲーションバーに認証ボタンを追加

ハイブリッドアプローチ

認証ボタンの実装には、サーバーサイドでセッションを取得し、クライアントコンポーネントに渡すハイブリッドアプローチを採用します。

このアプローチのメリット:

  • 初回レンダリング時のローディング表示が不要
  • SSRでセッション状態が確定した状態でHTMLを生成
  • クライアントへの初期JavaScriptバンドルサイズ削減

認証ボタンコンポーネントを作成

src/components/auth-button.tsx:

"use client";

import { Button } from "@/components/ui/button";
import type { Session } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";

type Props = {
  session: Session | null;
};

export function AuthButton({ session }: Props) {
  if (session) {
    return (
      <Button
        variant="ghost"
        onClick={() =>
          authClient.signOut({
            fetchOptions: {
              onSuccess: () => {
                window.location.reload();
              },
            },
          })
        }
      >
        ログアウト
      </Button>
    );
  }

  return (
    <Button
      variant="ghost"
      onClick={() =>
        authClient.signIn.social({
          provider: "google",
          callbackURL: "/",
        })
      }
    >
      ログイン
    </Button>
  );
}

ポイント:

  • session を props として受け取る(サーバーで取得済み)
  • useSession フックを使用しない(ローディング状態が不要)
  • Button コンポーネントで統一されたUI

signIn.social のオプション

await authClient.signIn.social({
  provider: "google",           // プロバイダー ID
  callbackURL: "/",             // 認証成功後のリダイレクト先
  errorCallbackURL: "/error",   // エラー時のリダイレクト先(オプション)
  newUserCallbackURL: "/welcome", // 新規ユーザー登録時のリダイレクト先(オプション)
});

ナビゲーションバーを更新(サーバーコンポーネント)

src/components/navbar.tsx をサーバーコンポーネントとして実装し、セッションを取得します:

import { Search } from "lucide-react";
import { headers } from "next/headers";
import Link from "next/link";
import { AuthButton } from "@/components/auth-button";
import { ThemeToggle } from "@/components/theme-toggle";
import { auth } from "@/lib/auth";

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

  return (
    <nav className="border-b bg-background">
      <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
        <div className="flex items-center gap-6">
          <Link
            href="/"
            className="flex items-center gap-2 hover:opacity-80 transition-opacity"
          >
            {/* ロゴ */}
            <span className="font-bold tracking-tight font-logo">linto</span>
          </Link>
          <Link
            href="/articles"
            className="text-sm font-medium hover:text-foreground/80 transition-colors"
          >
            Articles
          </Link>
        </div>
        <div className="flex items-center gap-4">
          <AuthButton session={session} />
          <Link
            href="/tags"
            className="p-2 hover:bg-accent rounded-md transition-colors"
            aria-label="Search by tags"
          >
            <Search size={18} aria-hidden="true" />
          </Link>
          <ThemeToggle />
        </div>
      </div>
    </nav>
  );
}

ポイント:

  • async function Navbar() としてサーバーコンポーネントに変更
  • auth.api.getSession({ headers: await headers() }) でサーバーサイドでセッション取得
  • AuthButtonsession を props として渡す

Google Cloud Console の設定

OAuth 同意画面の設定

  1. Google Cloud Console にアクセス
  2. 「APIとサービス」→「OAuth 同意画面」を選択
  3. ユーザーの種類を選択(外部 or 内部)
  4. アプリ情報を入力
  5. スコープで emailprofileopenid を追加

認証情報の作成

  1. 「APIとサービス」→「認証情報」を選択
  2. 「認証情報を作成」→「OAuth クライアント ID」
  3. アプリケーションの種類:「ウェブ アプリケーション」
  4. 承認済みの JavaScript 生成元を追加:
    • http://localhost:3000(開発用)
    • https://your-domain.com(本番用)
  5. 承認済みのリダイレクト URI を追加:
    • http://localhost:3000/api/auth/callback/google(開発用)
    • https://your-domain.com/api/auth/callback/google(本番用)

Important: リダイレクト URI は BETTER_AUTH_URL + /api/auth/callback/google の形式になります。

完成したプロジェクト構造

src/
├── app/
│   └── api/
│       └── auth/
│           └── [...all]/
│               └── route.ts      # Better Auth API ハンドラー
├── components/
│   ├── navbar.tsx                # ナビゲーションバー(AuthButton を追加)
│   └── auth-button.tsx           # 認証ボタンコンポーネント
├── db/
│   ├── index.ts                  # DB 接続設定(env を使用)
│   └── schema/
│       ├── auth.ts               # 認証スキーマ(CLI で自動生成)
│       └── index.ts              # スキーマエクスポート
├── lib/
│   ├── auth.ts                   # Better Auth サーバー設定(env を使用)
│   └── auth-client.ts            # Better Auth クライアント設定
└── env.ts                        # t3-env 環境変数定義

セットアップ手順のまとめ

# 1. パッケージインストール
npm install better-auth

# 2. 環境変数を設定(.env.local)

# 3. src/env.ts に環境変数のスキーマを追加

# 4. src/lib/auth.ts を作成

# 5. スキーマを自動生成
npx @better-auth/cli generate --output src/db/schema/auth.ts

# 6. マイグレーションを実行
npm run db:generate
npm run db:migrate

# 7. API ルート、クライアント、UIコンポーネントを作成

トラブルシューティング

環境変数のバリデーションエラー

t3-env を使用している場合、環境変数が不足しているとアプリ起動時にエラーが発生します:

❌ Invalid environment variables: { GOOGLE_CLIENT_ID: [ 'Required' ] }

解決方法:

  1. .env.local に必要な環境変数がすべて設定されているか確認
  2. 環境変数の形式が正しいか確認(例: BETTER_AUTH_SECRET は32文字以上)

「redirect_uri_mismatch」エラー

このエラーは、Google に送信されるリダイレクト URI と Google Cloud Console に登録された URI が一致しない場合に発生します。

解決方法:

  1. .env.localBETTER_AUTH_URL が正しく設定されているか確認:
# 開発環境
BETTER_AUTH_URL=http://localhost:3000

# 本番環境
BETTER_AUTH_URL=https://your-domain.com
  1. Google Cloud Console のリダイレクト URI が正確か確認:
http://localhost:3000/api/auth/callback/google

「Invalid redirect URI」エラー

Google Cloud Console で設定したリダイレクト URI が正確か確認してください。末尾のスラッシュの有無にも注意が必要です。

CLI でスキーマが生成されない

auth.ts ファイルが正しい場所にあるか確認してください。CLI はデフォルトで以下の場所を検索します:

  • ./auth.ts
  • ./lib/auth.ts
  • ./utils/auth.ts
  • ./src/auth.ts
  • ./src/lib/auth.ts
  • ./src/utils/auth.ts

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

  1. BETTER_AUTH_SECRET が設定されているか確認(本番環境では必須)
  2. Cookie が正しく設定されているか確認(DevTools → Application → Cookies)
  3. BETTER_AUTH_URL が正しく設定されているか確認

CORS エラー

異なるドメイン間で通信する場合は、src/lib/auth.tstrustedOrigins を設定:

import { env } from "@/env";

export const auth = betterAuth({
  // ...
  trustedOrigins: [
    "http://localhost:3000",
    env.NEXT_PUBLIC_SITE_URL,
  ],
});

本番環境でエラーが発生する

BETTER_AUTH_SECRET が設定されていないと、本番環境ではエラーが発生します。t3-env を使用している場合、アプリ起動時にバリデーションエラーとして検出されます。

まとめ

Better Auth と t3-env を組み合わせることで、型安全で堅牢な Google 認証を実装できます。

主なポイント:

  1. t3-env で型安全な環境変数管理: バリデーションと型補完で設定ミスを防止
  2. CLI でスキーマ自動生成: 手動でスキーマを書く必要なし
  3. Drizzle 統合: ネイティブアダプターで型安全なデータベース操作
  4. ハイブリッドアプローチ: サーバーでセッション取得、クライアントで操作を実行
  5. SSR 対応: 初回レンダリングからセッション状態が確定