18
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Better Authは、包括的な認証機能フレームワークです。
(なんと認可機能もあるよ!)

様々な環境で利用できるため非常に汎用性が高くNuxtやHonoのような様々なフルスタック、バックエンドフレームワークで使用できます。

また、各機能はプラグインという概念になっており非常に拡張性が高いのも特徴です。
例として、マジックリンク認証、アカウント管理、組織機能 etc...などの多機能な公式プラグインが存在します。

今回のゴールは、「ユーザーがDBに保存され、ログインして自分の名前が表示できるところまで」までとします。

なぜBetter Authを使うのか

現在、Nuxtにある主要な認証ライブラリは以下の通りです。

  • nuxt-auth-utils
    • Nuxtの作者が作っている
  • @sidebase/nuxt-auth

大体sidebaseが利用される印象ですが、ベースがBetter Authに統合された「Auth.js」なので、「それならBetter Auth使ったほうがよくね?」となります。

そしたら、次の選択肢はnuxt-auth-utilsになると思いますが、機能が少し物足りなく(あえてそうしているのだと思うが)、Better Authに比べ実装が大変かもしれません。

そしてこれが一番の理由です。
両者ともに共通することですが、情報量がかなり少ないです。

それに対してBetter Authは後発とはいえ比較的情報量が多いのでおすすめできます。
また多機能とはいえ、どんどんプラグインベースで拡張していく形になるので扱いやすいのもあります。

前提条件

  • pnpmを使用します
  • Nuxtプロジェクトはpnpm create nuxt@latestで事前に作っておいてください
  • https://www.better-auth.com/docs/installation#set-environment-variables でインストールは事前にしていてください
  • とりあえずSQLite(better-sqlite3)を使用
  • とりあえず作ってみるのでバリデーションは省略

https://www.better-auth.com/docs/integrations/nuxt こちらを参考に共同で進めていきます。

better-sqlite3がCLIで動かない...

pnpm approve-builds を実行し、better-sqlite3を選択したら直ります。

セットアップ

lib/auth.ts の補足

パスワード認証を有効にするためにはauth.ts(設定ファイル)に以下のコードを追加してください。

lib/auth.ts
import { betterAuth } from "better-auth";
import Database from "better-sqlite3";

export const auth = betterAuth({
    database: new Database("./sqlite.db"),
    // パスワード認証を使うにはこの設定が必須!↓
    emailAndPassword: {  
        enabled: true 
    }
});

クライント側の設定

本番環境では環境変数を使うとGood

import { createAuthClient } from "better-auth/vue" // Vue向け

export const authClient = createAuthClient({
    baseURL: "http://localhost:3000"
})

APIエンドポイント セットアップ

おそらく、インストールは事前にしてあるはずなので、lib/auth.tsをルートパスで呼び出してください。
~~でルートパスにすることにより、../../ のような複雑なコードにすることをさけています。

そしてauth/**直下のAPIエンドポイントに以下のファイルを追加。

server/api/auth/[...all].ts
import { auth } from "~~/lib/auth";

export default defineEventHandler((event) => {
	return auth.handler(toWebRequest(event));
});

マイグレーションする

pnpx @better-auth/cli migrate

サインアップ(新規登録)をエンドポイントを作ろう

今回はZodバリデーターでスキーマを用いたバリデーションを行わないため、asResponse: trueが必要です。
なので今回は生のHTTPレスポンスをそのままぶち込みます。

本番環境ではバリデーションを絶対にしてください。

server/api/auth/sign-up/email.ts
import { auth } from '~~/lib/auth'
import { readBody, createError } from 'h3'

export default defineEventHandler(async (event) => {
  try {
    const body = await readBody(event)

    const { headers } = event
    const data = await auth.api.signUpEmail({
      body,
      headers,
      asResponse: true
    })

    console.log('サインアップ成功:', data)

    return data
  } catch (e: unknown) {
    if (e instanceof Error) {
      throw createError({
        statusCode: 400,
        message: 'サインアップに失敗しました'
      })
    }
    throw createError({ statusCode: 500, message: 'Internal Server Error' })
  }
})

【重要】新規登録画面を作ってみよう

適当に作りましょう。

pages/sign-up.vue
<script setup lang="ts">
import { authClient } from '~~/lib/auth-client'

const email = ref('')
const password = ref('')
const name = ref('')
const message = ref('')
const loading = ref(false)

const handleSignUp = async (event: Event) => {
  event.preventDefault()
  message.value = ''
  loading.value = true

  try {
    const { data, error } = await authClient.signUp.email({
      email: email.value,
      password: password.value,
      name: name.value,
    })
    if (error) {
      message.value = error.message || 'サインアップに失敗しました'
      return
    }

    // 成功時の処理
    console.log('サインアップ成功', data)
    message.value = 'サインアップに成功しました。'
  } catch (e: unknown) {
    if (e instanceof Error) message.value = e.message
    else message.value = '不明なエラーが発生しました'
  } finally {
    loading.value = false
  }
}

const session = authClient.useSession()
</script>

<template>
  <div>
    {{ session.data ? 'ログイン中' : 'ログインしていません' }}
    <form method="post" @submit="handleSignUp">
      <h1>アカウント作成</h1>
      <input v-model="name" placeholder="名前" required>
      <input v-model="email" type="email" placeholder="メールアドレス" required>
      <input v-model="password" type="password" placeholder="パスワード" required>
      <div v-show="message">
        {{ message }}
      </div>
      <button type="submit" :disabled="loading">
        {{ loading ? '読み込み中...' : '登録する' }}
      </button>
    </form>
  </div>
</template>

スクリーンショット 2025-12-14 210757.png

では、名前、ユーザー名とパスワード(8文字以上128文字以内)入力して登録してみましょう!

スクリーンショット 2025-12-14 210808.png

お!成功しましたね!
上の表示もログイン中に変わっています。

ついでにログイン(サインイン)も作る

APIエンドポイント

server/api/auth/sign-in/email.ts
import { auth } from '~~/lib/auth'
import { readBody, createError } from 'h3'

export default defineEventHandler(async (event) => {
  try {
    const body = await readBody(event)

    const { headers } = event
    const data = await auth.api.signInEmail({
      body,
      headers,
      asResponse: true
    })

    console.log('サインイン成功:', data)

    return data
  } catch (e: unknown) {
    if (e instanceof Error) {
      throw createError({
        statusCode: 400,
        message: 'サインインに失敗しました'
      })
    }
    throw createError({ statusCode: 500, message: 'Internal Server Error' })
  }
})

フロント側

pages/sign-in.vue
<script setup lang="ts">
import { authClient } from '~~/lib/auth-client'

const email = ref('')
const password = ref('')
const rememberMe = ref(true)
const message = ref('')
const loading = ref(false)

const handleSignIn = async (event: Event) => {
  event.preventDefault()
  message.value = ''
  loading.value = true

  try {
    const { data, error } = await authClient.signIn.email({
      email: email.value,
      password: password.value,
      rememberMe: rememberMe.value,
      callbackURL: '/dashboard'
    })

    if (error) {
      message.value = error.message || 'サインインに失敗しました'
      return
    }

    //成功時の処理
    console.log('サインイン成功', data)
    message.value = 'サインインに成功しました'
  } catch (e: unknown) {
    if (e instanceof Error) message.value = e.message
    else message.value = '不明なエラーが発生しました'
  } finally {
    loading.value = false
  }
}

const session = authClient.useSession()
</script>

<template>
  <div>
    {{ session.data ? 'ログイン中' : 'ログインしていません' }}
    <form method="post" @submit="handleSignIn">
      <h1>ログイン</h1>
      <input v-model="email" type="email" placeholder="メールアドレス" required>
      <input v-model="password" type="password" placeholder="パスワード" required>
      <label>
        <input v-model="rememberMe" type="checkbox"> ログイン状態を保持する
      </label>
      <div v-show="message">
        {{ message }}
      </div>
      <button type="submit" :disabled="loading">
        {{ loading ? '読み込み中...' : 'ログイン' }}
      </button>
    </form>
  </div>
</template>

ダッシュボード(仮)も作る。

dashboard.vue
<script setup lang="ts">
import { authClient } from '~~/lib/auth-client'

const session = authClient.useSession()
</script>

<template>
  <div>
    {{ session.data?.user.name ?? 'エラー!' }}
    <h1>ログイン中です!</h1>
  </div>
</template>

ログインも機能するかどうか試してみよう!

では先程のメールアドレスとパスワードを入力してみましょう!(GIF画像)

sign-in.gif

いいね!ダッシュボードに名前と「ログインしています」というのが表示されているのがわかると思います。

ミドルウェアで認証しないと見られないようにしよう!

ただし、ダッシュボードなどは普通、認証しないと見られないページですよね?
なのでミドルウェアを作って、セッションがないと見られないようにしましょう!

ですが、現在は既知の問題により、普通のやり方では機能しないため、このissueにある回避策を実行します。

middleware/auth.global.ts
import { authClient } from '~~/lib/auth-client'

export default defineNuxtRouteMiddleware(async to => {
  // ログインページは認証チェックをスキップ
  if (to.path === '/') {
    return;
  }
  // dashboardのみ認証チェック
  if (!to.path.startsWith('/dashboard')) {
    return;
  }

  // better-auth側がバグってusefetchが使えないため、一旦ラップする
  const relativeFetch = ((url: string, opts?: any) => {
    try {
      if (url.startsWith('http')) url = new URL(url).pathname;
    } catch (error: unknown) {
      console.error('Error parsing URL in auth middleware:', error);
    }
    return useFetch(url, opts);
  }) as any;
  const { data: session } = await authClient.useSession(relativeFetch);

  if (!session.value) {
    console.log('未認証のためリダイレクト');
    return navigateTo('/sign-in');
  }
});

試しに、セッションを持っていない(未認証)状態でダッシュボードにアクセスしてみましょう。

Middleware.gif

いいですね!
ちゃんと302リダイレクトされました

まとめ

今回はBetter AuthをNuxtで使用し、パスワードベースのサインアップとサインイン機能、さらにはミドルウェアによるルーティング保護までを実現しました。

非常に汎用性が高く、Nuxtでの統合も非常にスムーズでしたね。
わずかな設定で強力な認証システムを構築できることが分かったと思います。


おすすめの学習動画(おまけ)

こちらは英語かつNext.jsの動画になりますが、日本語ダビングがあるので英語がわからなくても大丈夫です。
基礎から応用まで用意されているので、一通り見たらだいたい理解できると思います。
(あとは根性)

useSessionとgetSessionの違い(補足)

useSessionはただのフック(クライアントサイドに適している)であり単体で完結できますが、getSessioはメソッドでありサーバー・クライアント両方で使えるのが大きな違いだと思います。

const { 
  data: session, 
  isPending,
  error,
  refetch
} = authClient.useSession()

useSession

  • リアクティブである
  • 主にクライント側で使う

getSession

  • リアクティブではない
  • 主にサーバー側で使う
18
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?