【保存版】Convex × TanStack Start × Better Auth 認証実装ガイド
はじめに
今回ですが、Convex・TanStack StartにBetter Authを統合させた認証機能の実装をしたので、実装手順・方法を紹介したいと思います。
また、今回の実装を行ったソースは以下となります。
TL;DR
- Convexを使用する場合、認証処理の中核(Better Authの主な機能実装)はConvex側で必要
- TanStack StartではBetter Authの client定義が必要かつConvexのSiteURLを設定する必要がある
- 本来であれば、beforeLoadで認証・未認証のハンドリングを行うべき(TanStack Start RC版ではバグがあるため断念)
- ConvexとTanStack Start側でURLが異なるため、辻褄が合うよう実装する必要がある
前提
まず、前提として、今回の技術スタックと処理フローを先に記載いたします。
技術スタック
- バックエンド: Convex
- フロントエンド: TanStack Start
- Form管理: TanStack Form
- 認証機能: Better Auth
処理フロー
-
Authorized
Componentでsessionの判定 - 認証済みの場合、
ProtectedRoute
にアクセス - 未認証の場合、
/sign-in
にリダイレクト(認証済みの場合、/
にリダイレクト)
a./sign-up
は未認証でアクセス可能・認証済みでは/
にリダレクト - 画面からConvexに認証リクエスト
- Better AuthのHandlerで処理を実施
前提の共有が完了したところで、いよいよ実装に入っていきます。
実装
実装手順は以下で、解説手順も同じ順に行います。
- 環境構築(Convex・TanStack Start統合)
- Convex・TanStack Startの両者にBetter Auth導入
- SignIn・SignUp・SignOutページの作成
- GitHub OAuth設定
- Session取得処理実装
-
Authorized Component
作成
1. 環境構築(Convex・TanStack Start統合)
まずは、環境構築です。
以下のドキュメントを参考に実施していきます。
ドキュメント通り、以下のコマンドでTanStack Start・Convexのテンプレートの作成をしていきます。
※ テンプレートでなく、1から統合していく場合、TanStack Startアプリケーション作成後、本ドキュメントの手順および、コードを取得して統合するようにしてください。
bun create convex@latest -- -t tanstack-start
コマンド完了後、プロジェクトディレクトリに移動し、Convexのサーバーを起動します。
bunx convex dev
この時、ConvexにConvexプロジェクトがない場合、新規に作成する必要があるので、コマンドの手順に従い、Convexプロジェクトのセットアップを行ってください。
次に、TanStack Startが起動できるかも確認します。
bun dev:web
起動が確認でき、http://localhost:3000
にアクセスできたら、環境構築は完了です。
2. Convex・TanStack Startの両者にBetter Auth導入
次に、Better Authを両者に統合していきます。
こちらはBetter AuthのConvex integrationsを参考にしていきます。
まずは、ドキュメントに従い必要なパッケージを導入してきます。
bun add better-auth@1.3.8 --exact
bun add convex@latest @convex-dev/better-auth
次に、Convex側のBetter Auth環境を構築していきます。
Better Auth Convex統合
Convexの各種Config作成
まずは、convex.config.ts
を作成し、以下を記述します。
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;
次に、auth.config.ts
を作成し、以下を記述します。
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
]
};
Convex環境変数設定
各コマンドを実行し、環境変数を設定していきます。
bun x convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
bun x convex env set SITE_URL http://localhost:3000
コマンド実行後、Convexダッシュボードに反映されていることを確認します。
ローカル環境変数設定
続いて、ローカル環境変数を設定します。
Convexサーバーを初回に起動したため、.env.local
がすでに作成されており、Convexに必要な環境変数が定義されていると思います。
そのため、以下の2つの変数を追加します。
# Deployment used by `npx convex dev`
CONVEX_DEPLOYMENT=dev:xxxxxxxx-yyyyy-zzz
VITE_CONVEX_URL=https://xxxxxxxx-yyyyy-zzz.convex.cloud
+ # Same as VITE_CONVEX_URL but ends in .site
+ VITE_CONVEX_SITE_URL=https://xxxxxxxx-yyyyy-zzz.convex.site
+ # Your local site URL
+ SITE_URL=http://localhost:3000
Better Auth Instance作成
ここはドキュメントと同じく、createAuth
を定義していきます。
ここで、OAuthなど、どの認証処理を行うのかの定義を追加していきます。
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth } from "better-auth";
const siteUrl = process.env.SITE_URL!;
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth);
export const createAuth = (
ctx: GenericCtx<DataModel>,
{ optionsOnly } = { optionsOnly: false },
) => {
return betterAuth({
// disable logging when createAuth is called just to generate options.
// this is not required, but there's a lot of noise in logs without it.
logger: {
disabled: optionsOnly,
},
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The Convex plugin is required for Convex compatibility
convex(),
],
});
};
// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});
HTTP handler追加
次に、ConvexがBetter AuthのルートをMountできるようhttp.ts
を作成します。
簡単に言うと、Convex 上で HTTP エンドポイントを作って、Better Auth の認証用ルートをマウントする処理を記述します。
これがあるおかげで、フロントエンドのauthClient
でBetter Authの各種認証用ルートにConvex経由でアクセスすることができます。
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;
これでConvexでのBetter Auth統合は完了です。
Better Auth TanStack Start統合
こちらで行うことは以下の2つだけです。
- AuthClientの作成
- ConvexBetterAuthProviderでRouteをラップする
1. AuthClientの作成
まずは,AuthClientの作成です。
lib/auth-client.ts
を作成し、convexClient()
をplugin指定して、フロントエンドで使う認証用Clientを作成します。
注意としては、baseURL
にはConvex側のSITE URLを指定する必要があることです。
アプリケーション側のURL(http://localhost:3000など)を指定するのは誤りであるということです。
import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
// Convexのhttpルート経由でBetter Authエンドポイントにアクセス
// Better AuthのデフォルトbasePath '/api/auth' を使用
baseURL: `${import.meta.env.VITE_CONVEX_SITE_URL}/api/auth`,
plugins: [convexClient()],
})
2. ConvexBetterAuthProviderでRouteをラップする
以下の修正をrouter.tsx
行います。
- ConvexReactClientの作成
- ConvexProviderをConvexBetterAuthProviderに修正
import { ConvexBetterAuthProvider } from '@convex-dev/better-auth/react'
import { ConvexQueryClient } from '@convex-dev/react-query'
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
import { ConvexReactClient } from 'convex/react'
import { authClient } from '~/lib/auth-client'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!
if (!CONVEX_URL) {
console.error('missing envar CONVEX_URL')
}
const convex = new ConvexReactClient(CONVEX_URL, {
// Optionally pause queries until the user is authenticated
expectAuth: true,
})
const convexQueryClient = new ConvexQueryClient(CONVEX_URL)
const queryClient: QueryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
gcTime: 5000,
},
},
})
convexQueryClient.connect(queryClient)
const router = routerWithQueryClient(
createRouter({
routeTree,
defaultPreload: 'intent',
context: { queryClient },
scrollRestoration: true,
defaultPreloadStaleTime: 0, // Let React Query handle all caching
defaultErrorComponent: (err) => <p>{err.error.stack}</p>,
defaultNotFoundComponent: () => <p>not found</p>,
Wrap: ({ children }) => (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
{children}
</ConvexBetterAuthProvider>
),
}),
queryClient,
)
return router
}
ConvexReactClient
ではexpectAuth
を有効にしており、これを有効にすることで、未認証のユーザーがQuery・Mutatoinなどが行えないようにします。
例えば、Mutationの場合、ボタン自体は表示されますが、ボタンを押しても未認証の場合、何も反応がなく、リクエストさせることを防ぐことができます。
その他、ConvexReactClient
には便利なオプションが提供されてるので詳細は、以下のドキュメントを参照してください。
以上で、手順2のBetter Auth統合が完了です。
3. SignIn・SignUp・SignOutページの作成
次にフロント側でUIを作成してきます。
※ いずれのページも主にJSX部分はAIで作成したため、冗長な箇所があると思いますが、ご容赦ください
SignIn ページの作成
ソースは以下になります。
全体像は以下です。
import { useForm } from '@tanstack/react-form'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { AlertCircle, Github, Loader2, Lock, Mail } from 'lucide-react'
import { useTransition } from 'react'
import { authClient } from '~/lib/auth-client'
export const Route = createFileRoute('/auth/sign-in')({
component: SignIn,
})
function SignIn() {
const navigate = useNavigate()
const [isPending, startTransition] = useTransition()
const form = useForm({
defaultValues: {
email: '',
password: '',
error: '',
},
onSubmit: ({ value, formApi }) => {
formApi.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({ to: '/' })
},
onError: (ctx) => {
formApi.setFieldValue('error', ctx.error.message || 'ログインに失敗しました')
},
},
)
} catch (_err) {
formApi.setFieldValue('error', '予期しないエラーが発生しました')
}
})
},
})
const handleGitHubSignIn = () => {
form.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signIn.social({
provider: 'github',
callbackURL: 'http://localhost:3000/',
})
} catch (_err) {
form.setFieldValue('error', 'GitHub認証に失敗しました')
}
})
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 dark:from-slate-900 dark:to-slate-800">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="font-bold text-3xl text-slate-900 dark:text-slate-50">ログイン</h1>
<p className="mt-2 text-slate-600 text-sm dark:text-slate-400">
アカウントにログインしてください
</p>
</div>
<div className="rounded-lg bg-white p-8 shadow-lg dark:bg-slate-800">
{form.state.values.error && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-red-50 p-3 text-red-800 text-sm dark:bg-red-900/20 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{form.state.values.error}</span>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
className="space-y-4"
>
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
!value
? 'メールアドレスは必須です'
: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? 'メールアドレスの形式が正しくありません'
: undefined,
}}
>
{(field) => (
<div>
<label
htmlFor={field.name}
className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
>
メールアドレス
</label>
<div className="relative">
<Mail className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
<input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
required
className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
placeholder="your@email.com"
disabled={isPending}
/>
</div>
{field.state.meta.errors.length > 0 && (
<p className="mt-1 text-red-600 text-xs dark:text-red-400">
{field.state.meta.errors[0]}
</p>
)}
</div>
)}
</form.Field>
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
!value
? 'パスワードは必須です'
: value.length < 8
? 'パスワードは8文字以上で入力してください'
: undefined,
}}
>
{(field) => (
<div>
<label
htmlFor={field.name}
className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
>
パスワード
</label>
<div className="relative">
<Lock className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
required
minLength={8}
className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
placeholder="••••••••"
disabled={isPending}
/>
</div>
{field.state.meta.errors.length > 0 && (
<p className="mt-1 text-red-600 text-xs dark:text-red-400">
{field.state.meta.errors[0]}
</p>
)}
</div>
)}
</form.Field>
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-slate-800"
>
{isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
ログイン中...
</>
) : (
'ログイン'
)}
</button>
</form>
<div className="my-6 flex items-center">
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
<span className="px-4 text-slate-500 text-sm dark:text-slate-400">または</span>
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
</div>
<button
type="button"
onClick={handleGitHubSignIn}
disabled={isPending}
className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:focus:ring-offset-slate-800 dark:hover:bg-slate-600"
>
<Github className="h-5 w-5" />
GitHubでログイン
</button>
<div className="mt-6 text-center text-slate-600 text-sm dark:text-slate-400">
アカウントをお持ちでないですか?{' '}
<Link
to="/auth/sign-up"
className="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
新規登録
</Link>
</div>
</div>
</div>
</div>
)
}
UIは以下のようなものになります。
特に押さえておきたい部分はuseFormのonSubmit
の処理とhandleGitHubSignIn
になります。
ここでauthClient
を通じて、認証用APIにアクセスします。
また、特に注意が必要なのはauthClient.signIn.social
でcallbackUrl
の設定が必要なことです。
これをアプリケーション側のURLにしないと、ConvexのSITE URLに認証後リダイレクトされていまします。
ですので、ここは忘れずに設定しておいてください。
const form = useForm({
defaultValues: {
email: '',
password: '',
error: '',
},
onSubmit: ({ value, formApi }) => {
formApi.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({ to: '/' })
},
onError: (ctx) => {
formApi.setFieldValue('error', ctx.error.message || 'ログインに失敗しました')
},
},
)
} catch (_err) {
formApi.setFieldValue('error', '予期しないエラーが発生しました')
}
})
},
})
const handleGitHubSignIn = () => {
form.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signIn.social({
provider: 'github',
callbackURL: 'http://localhost:3000/',
})
} catch (_err) {
form.setFieldValue('error', 'GitHub認証に失敗しました')
}
})
}
SignUp ページの作成
こちらのソースは以下です。
また、全体像は以下のようになります。
こちらも重要な箇所や注意点はsign-in.tsx
と同じになります。
import { useForm } from '@tanstack/react-form'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { AlertCircle, Github, Loader2, Lock, Mail, User } from 'lucide-react'
import { useTransition } from 'react'
import { authClient } from '~/lib/auth-client'
export const Route = createFileRoute('/auth/sign-up')({
component: SignUp,
})
function SignUp() {
const navigate = useNavigate()
const [isPending, startTransition] = useTransition()
const form = useForm({
defaultValues: {
name: '',
email: '',
password: '',
error: '',
},
onSubmit: ({ value, formApi }) => {
formApi.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signUp.email(
{
name: value.name,
email: value.email,
password: value.password,
},
{
onSuccess: () => {
// デフォルトでautoSignIn: trueなので、自動的にサインインされてセッションが取得される
navigate({ to: '/' })
},
onError: (ctx) => {
formApi.setFieldValue('error', ctx.error.message || '登録に失敗しました')
},
},
)
} catch (_err) {
formApi.setFieldValue('error', '予期しないエラーが発生しました')
}
})
},
})
const handleGitHubSignUp = () => {
form.setFieldValue('error', '')
startTransition(async () => {
try {
await authClient.signIn.social({
provider: 'github',
callbackURL: 'http://localhost:3000/',
})
} catch (_err) {
form.setFieldValue('error', 'GitHub認証に失敗しました')
}
})
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 dark:from-slate-900 dark:to-slate-800">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="font-bold text-3xl text-slate-900 dark:text-slate-50">新規登録</h1>
<p className="mt-2 text-slate-600 text-sm dark:text-slate-400">
アカウントを作成してください
</p>
</div>
<div className="rounded-lg bg-white p-8 shadow-lg dark:bg-slate-800">
{form.state.values.error && (
<div className="mb-4 flex items-center gap-2 rounded-md bg-red-50 p-3 text-red-800 text-sm dark:bg-red-900/20 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{form.state.values.error}</span>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
className="space-y-4"
>
<form.Field
name="name"
validators={{
onChange: ({ value }) => (!value ? 'お名前は必須です' : undefined),
}}
>
{(field) => (
<div>
<label
htmlFor={field.name}
className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
>
お名前
</label>
<div className="relative">
<User className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
<input
id={field.name}
name={field.name}
type="text"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
required
className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
placeholder="山田 太郎"
disabled={isPending}
/>
</div>
{field.state.meta.errors.length > 0 && (
<p className="mt-1 text-red-600 text-xs dark:text-red-400">
{field.state.meta.errors[0]}
</p>
)}
</div>
)}
</form.Field>
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
!value
? 'メールアドレスは必須です'
: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? 'メールアドレスの形式が正しくありません'
: undefined,
}}
>
{(field) => (
<div>
<label
htmlFor={field.name}
className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
>
メールアドレス
</label>
<div className="relative">
<Mail className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
<input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
required
className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
placeholder="your@email.com"
disabled={isPending}
/>
</div>
{field.state.meta.errors.length > 0 && (
<p className="mt-1 text-red-600 text-xs dark:text-red-400">
{field.state.meta.errors[0]}
</p>
)}
</div>
)}
</form.Field>
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
!value
? 'パスワードは必須です'
: value.length < 8
? 'パスワードは8文字以上で入力してください'
: undefined,
}}
>
{(field) => (
<div>
<label
htmlFor={field.name}
className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
>
パスワード
</label>
<div className="relative">
<Lock className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
<input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
required
minLength={8}
className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
placeholder="8文字以上"
disabled={isPending}
/>
</div>
{field.state.meta.errors.length > 0 && (
<p className="mt-1 text-red-600 text-xs dark:text-red-400">
{field.state.meta.errors[0]}
</p>
)}
{field.state.meta.errors.length === 0 && (
<p className="mt-1 text-slate-500 text-xs dark:text-slate-400">
パスワードは8文字以上で入力してください
</p>
)}
</div>
)}
</form.Field>
<button
type="submit"
disabled={isPending}
className="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-slate-800"
>
{isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
登録中...
</>
) : (
'アカウント作成'
)}
</button>
</form>
<div className="my-6 flex items-center">
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
<span className="px-4 text-slate-500 text-sm dark:text-slate-400">または</span>
<div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
</div>
<button
type="button"
onClick={handleGitHubSignUp}
disabled={isPending}
className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:focus:ring-offset-slate-800 dark:hover:bg-slate-600"
>
<Github className="h-5 w-5" />
GitHubで登録
</button>
<div className="mt-6 text-center text-slate-600 text-sm dark:text-slate-400">
既にアカウントをお持ちですか?{' '}
<Link
to="/auth/sign-in"
className="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
ログイン
</Link>
</div>
</div>
</div>
</div>
)
}
UIは以下のようになってます。
SignOut ページの作成
こちらのソースは以下です。
こちらはUIの実装はありませんが、TanStack RouterのbeforeLoad
という機能を用いてsignOut機能を実装しています。
authClient.signOut()
実施後、signInページにリダイレクトするというものになっております。
import { createFileRoute, redirect } from '@tanstack/react-router'
import { authClient } from '~/lib/auth-client'
export const Route = createFileRoute('/auth/sign-out')({
beforeLoad: async ({ preload }) => {
if (preload) {
return
}
await authClient.signOut()
throw redirect({
to: '/auth/sign-in',
})
},
})
UI上では、以下のように使用することができ、これだけで、signOutが実施されます。
import { useNavigate } from '@tanstack/react-router'
export function SignOutButton() {
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => navigate({ to: '/auth/sign-out' })}
className="cursor-pointer text-blue-600 underline hover:no-underline"
>
Sign out
</button>
)
}
ここまでで認証機能自体の作成は完了しました。
そのため、この時点で、Email/Passwordの認証は機能します。
ただ、まだGitHub OAuthの機能は動作しないので、次項でその部分の処理と設定を行っていきます。
4. GitHub OAuth 設定実装
こちらもBetter Authドキュメントに従い、実施してきます。
まずは、GitHub Appの作成からです。
GitHub Appの作成
アプリを作成したら、Client ID
とClient secrets
を取得します。
また、Homepage URL
とAuthorization callback URL
も設定します。
ここでもCONVEX_SITE_URLに設定したConvexのSITE URLを指定するにしてください。
つまりそれぞれ、以下の値を設定することになります。
- Homepage URL: ConvexのSITE URL
- Authorization callback URL: ConvexのSITE URL/api/auth/callback/github
Convexに環境変数追加
ここではConvex側にのみ環境変数を追加します。
OAuth処理はConvexが持つことになるため、ローカル環境変数の設定は不要です。
以下のコマンドで環境変数を設定します。
bun x convex env set GITHUB_CLIENT_ID your-client-id
bun x convex env set GITHUB_CLIENT_SECRET your-client-secret
Convex コードの実装
次に、Convex側にGitHub OAuth認証が実施できるよう処理・設定を記述していきます。
各ファイルに以下のように記述を加えていきます。
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
+ socialProviders: {
+ github: {
+ clientId: process.env.GITHUB_CLIENT_ID!,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
+ }
},
};
こちらはConvexのSITE URLとアプリケーション側のURLが異なるため、CORSの設定をしておきます。
これがないと、TanStack Startで認証リクエストした際にCORSのエラーが発生することになります。
※ 追加でconvex/auth.ts
にも設定を加えないとCORSが解消できないので、これだけで解決できるというわけではありません。
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
const http = httpRouter();
+ authComponent.registerRoutes(http, createAuth, { cors: true });
export default http;
こちらでは、socialProviders
の設定を追加するとともに、クロスドメイン時のCookie送信設定も追加してきます。
ここまででCORSのエラーが解消できます。
また、baseUrl
もConvexのSITE URLに修正し、GitHub Appの設定と合わせておきます。
このようにすることで、以下のようなフローができます。
- 画面からGitHub OAuth認証リクエスト(フロントエンドでは
callbackUrl
にアプリケーションのURLを指定してリクエスト) - ConvexがBetter Authエンドポイントにリクエストし、認証実施
- GitHub OAuth認証をし、GitHub Appで指定したAuthorization callback URL(ConvexのSITE URL)にリダイレクト
- Convexで認証結果のレスポンスを受け取る
- フロントエンドで指定した
callbacksUrl
にリダイレクト
この設定により、GitHub OAuth 認証フローとフロントエンドの画面遷移を 一貫した流れとして扱える ようになります。ユーザーは GitHub で認証した後、自動的にアプリケーションの指定した画面に戻り、そのままログイン済みの状態で利用を開始できます。
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { action, query } from "./_generated/server";
import { betterAuth } from "better-auth";
const siteUrl = process.env.SITE_URL!;
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth);
export const createAuth = (
ctx: GenericCtx<DataModel>,
{ optionsOnly } = { optionsOnly: false },
) => {
return betterAuth({
// disable logging when createAuth is called just to generate options.
// this is not required, but there's a lot of noise in logs without it.
logger: {
disabled: optionsOnly,
},
+ trustedOrigins: [
+ 'http://localhost:3000',
+ process.env.CONVEX_SITE_URL!,
+ ],
- baseURL: siteUrl,
+ baseURL: process.env.CONVEX_SITE_URL!,
database: authComponent.adapter(ctx),
+ socialProviders: {
+ github: {
+ clientId: process.env.GITHUB_CLIENT_ID!,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
+ }
+ },
// Configure simple, non-verified email/password to get started
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The Convex plugin is required for Convex compatibility
convex(),
],
+ // ? getSessionでsessionを取得できないため
+ // クロスドメインでのCookie送信を許可
+ advanced: {
+ // セッションを24時間に設定
+ sessionMaxAge: 24 * 60 * 60,
+ // Cookie に Secure 属性を付与(HTTPS必須)
+ useSecureCookies: process.env.NODE_ENV === "production",
+
+ // クロスオリジンで Cookie を送信するには SameSite=none が必要
+
+ defaultCookieAttributes: {
+ sameSite: process.env.NODE_ENV === "development" ? "lax" : "none",
+ },
+ },
});
};
// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});
これで、以下のGifのようにGitHub OAuthがうまく動作するはずです。
5. Session取得処理実装
ここまでで、認証処理自体は完全に実装できたので、sessionからユーザー情報を取得する処理を記述してみましょう。
以下のように、authClient.useSession()
を呼び出すことで、Sessionに含まれているユーザー情報を取得することができます。
import { createFileRoute } from '@tanstack/react-router'
import { authClient } from '~/lib/auth-client'
export const Route = createFileRoute('/')({
component: Home,
})
function Home() {
const { data: session } = authClient.useSession()
return (
<p>Welcome {session?.user.name}!</p>
)
}
6. Authorized Component 作成
最後に認証・未認証時の画面制御処理を加えて実装を完了しようと思います。
今回は以下のコンポーネントを使用してこの機能を実現しています。
import { useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { Loader } from '~/components/loader'
import { authClient } from '~/lib/auth-client'
const PUBLIC_ROUTES = ['/auth/sign-in', '/auth/sign-up']
export function Authorized({ children }: Record<'children', React.ReactNode>) {
const navigate = useNavigate()
const { data: session, isPending } = authClient.useSession()
useEffect(() => {
if (!isPending && !session?.user) {
navigate({
to: '/auth/sign-in',
search: {
redirect: location.pathname,
},
})
} else if (session?.user && PUBLIC_ROUTES.includes(location.pathname)) {
// User is authenticated, do nothing
navigate({
to: '/',
})
}
}, [session, isPending, navigate])
if (isPending) {
return <Loader />
}
return children
}
そのため、Home画面であれば、以下のように使用することができます。
export const Route = createFileRoute('/')({
component: RootComponent,
})
function RootComponent() {
return (
<Authorized>
<Home />
</Authorized>
)
}
また、/auth/sign-in.tsx
や/auth/sing-up.tsx
など/auth
というRouteに複数の子Routeがある場合、TanSack Routerではroute.tsx
を使用できます。
これは、共通Layoutなどを記述するためのものです。
今回であれば、以下のようにすることができます。
これで、認証・未認証時のリダレクト機能が実装できます。
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { Authorized } from '~/components/authorized'
export const Route = createFileRoute('/auth')({
component: RouteComponent,
})
function RouteComponent() {
return (
<Authorized>
<Outlet />
</Authorized>
)
}
ただ、このようなケースの場合、私としてはTanStack RouterのbeforeLoad
を使うべきと考えています。
ただ、現状、私の環境では動作が安定せず、TanStack Startの依存関係でエラーがでるなどしたため、こちらの実装をしております。
これらが安定し、TanStack Startが真に安定版となった際は、beforeLoad
で行追うと思います。
最後に
今回、Convex・TanStack Start・Better Authと比較的新しい技術で実装しましたが、個人的には情報が少なくかなり苦戦したというのが正直なところなので、この記事が参考になる方がいたら幸いです!
参考文献
Discussion