🟠 0.はじめに
Todoアプリのハンズオンは、Web開発の「Hello World」として定番ですよね。
しかし、実用的なアプリにするために「認証機能」を追加しようとすると、途端に難易度が跳ね上がります。
「自分のTodoだけを表示し、他人のTodoは見せない」
この当たり前の要件を満たすために、昔は多くのボイラープレートコードが必要でした。
しかし 2026年の今、Better Auth とモダンなスタックを組み合わせることで、驚くほどシンプルに実装できます。
今回は、以下の最新技術スタックを用いて、認証付きTodoアプリをゼロから構築します。
- 認証: Better Auth(堅牢かつ型安全な認証基盤)
- DB: Neon DB (Serverless PostgreSQL)
- ORM: Drizzle ORM (TypeScriptとの相性が抜群)
- UI: shadcn/ui
初期構築の重要性
複数のライブラリ(スタック)を連携させる初期設定は少し手間がかかりますが、ここを乗り越えれば型安全性と堅牢なセキュリティが手に入ります。
設定手順はステップ数が多いですが、一つずつ確実に進めていきましょう。
情報の鮮度について
本記事は 2026年1月現在 の最新バージョンに基づいています。 Better Auth や Drizzle などのモダンなライブラリは開発スピードが非常に速く、短期間で**破壊的変更(Breaking Changes)**が入る可能性もあります。実装の際は必ず公式ドキュメントも併せてご確認ください。
アプリのイメージ
🟠 1.Next.jsのプロジェクト作成
公式のコマンド
npx create-next-app@latest [project-name] [options]
参考
npx create-next-app@latest betterauth-todo --yes
🟠 2.利用するDB環境の構築
今回は Neon DB を利用します。
🟠 3.Drizzle ORM のインストール
✅ (1) Drizzle ORM インストールコマンド
公式のコマンド
npm i drizzle-orm @neondatabase/serverless dotenv
npm i -D drizzle-kit tsx
✅ (2) プロジェクトのルートに .env ファイルを作成
Neon DBから取得した接続文字列を記述します
.env
DATABASE_URL=postgresql://neondb_owner:xxxxxxxxxxxx@ep-frosty-union-a1begdsf-pooler.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
✅ (3) src/db/drizzle.ts ファイルを作成
import { config } from "dotenv";
import { drizzle } from 'drizzle-orm/neon-http';
config({ path: ".env" });
export const db = drizzle(process.env.DATABASE_URL!);
✅ (4) src/db/schema.ts ファイルを作成
DBスキーマはBetter Authが自動作成するのでファイルの中身は空で進めます
✅ (5) drizzle.config.ts ファイルを作成
プロジェクトのルートに drizzle.config.ts を作成します
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
config({ path: ".env" });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
🟠 4.Better Auth のインストール
✅ (1) Better Auth インストールコマンド
公式のコマンド
# プロジェクトのルートディレクトリで実行すること
npm install better-auth
✅ (2) .env ファイルに環境定数を追加
.env
DATABASE_URL=postgresql://neondb_owner:xxxxxxxxxxxx@ep-frosty-union-a1begdsf-pooler.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require
+ BETTER_AUTH_SECRET={あなたのシークレット}
+ BETTER_AUTH_URL=http://localhost:3000 # Base URL of your app
シークレットは十分な長さのランダムな文字列ならなんでもOKですが、BetterAuthの公式ページにシークレットの生成ボタンがあります。
✅ (3) /src/lib/auth.ts ファイルを作成
Better Authの基本設定になります。
利用する Better Auth の機能や 利用するORMなどによって記述内容は変わります。
ここでは以下の条件の設定例を示してます。
- 認証情報は
drizzle経由のPostgresへ格納する - 認証はemail + パスワードのみとする
- セッションの有効期限を設定する
import { db } from "@/db/drizzle";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
// 1) 認証情報は `drizzle` 経由の `Postgres` へ格納する
database: drizzleAdapter(db, {
provider: "pg",
}),
// 2) 認証はemail + パスワードのみとする
emailAndPassword: {
enabled: true,
},
// 3) セッションの有効期限を設定する
session: {
expiresIn: 60 * 60 * 24 * 7, // 7日 (seconds)
updateAge: 60 * 60 * 24 * 1, // 1日ごとに有効期限を更新
},
// 4) プラグインを設定する
plugins: [
nextCookies(), // 常に配列の最後に配置
],
});
✅ (4) Better Auth スキーマの生成コマンド
npx @better-auth/cli generate
✔ Do you want to generate the schema to ./auth-schema.ts? … yes
確認されるので yes でスキーマを生成します
✅ (5) 自動生成された ./auth-schema.ts の中身を src/db/schema.ts へコピーし ./auth-schema.ts を削除します
✅ (6) スキーマが完成したので drizzle 経由で Neon DBへ反映します
npx drizzle-kit push
✅ (7) Neon DB へスキーマが反映されたことを確認します
✅ (8) スキーマの定義を export します
src/db/schema.ts の 最後に export を追加
+ export const schema = { user, session, account, verification };
✅ (9) スキーマの定義をatuh.tsへ認識させます
src/lib/auth.ts の 修正
import { db } from "@/db/drizzle";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
+ import { schema } from "@/db/schema";
export const auth = betterAuth({
// 1) 認証情報は `drizzle` 経由の `Postgres` へ格納する
database: drizzleAdapter(db, {
provider: "pg",
+ schema: schema,
}),
✅ (10) APIルートハンドラーファイルを追加します
src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { POST, GET } = toNextJsHandler(auth);
✅ (11) Better Auth のクライアント用SDKファイルを作成します
src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
// -----------------------------------------------------------
// Client SDK
// 一般的なユーザー操作(トリガー)はこのSDKがカバーする
// -----------------------------------------------------------
// ログイン (signIn)
// 新規登録 (signUp)
// ログアウト (signOut)
// -- 非推奨 -------------------------------------------------
// クライアント側でのセッション取得 (useSession) ※ React Hook (監視用: UI連動)
// クライアント側でのセッション取得 (getSession) ※ Async Func (点呼用: イベント時)
// ※ データ取得は原則 Server Components (Page/Layout) で行い、Propsで渡すこと
// -----------------------------------------------------------
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, // 例: http://localhost:3000
});
export const { signIn, signUp, signOut } = authClient;
🟠 5.動作確認
ここまでの設定が問題なく動作するか確認しましょう。
ルートページにボタンを作り、ダミーのユーザー情報をSinUpして動作確認しましょう。
✅ (1) 確認用のルートページ
/src/app/page.tsx
"use client";
import { signUp } from "@/lib/auth-client";
export default function Home() {
// Better Auth のクライアントSDK signUp.email を実行する
const handleSignUp = async () => {
const { error } = await signUp.email({
email: "test@example.com",
password: "securePassword123",
name: "Test User",
});
if (error) {
console.error("Error signing up:", error.message);
} else {
console.log("Sign up successful!");
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<button
onClick={handleSignUp}
className="bg-slate-900 hover:bg-slate-700 text-white font-bold py-3 px-8 rounded-full transition-colors duration-200 transform hover:scale-105 active:scale-95"
>
Sign Up
</button>
</div>
);
}
✅ (2) ボタンクリック時のコンソール出力
※ SignUp に成功しました
✅ (3) ターミナルの確認
masayahak@masayahak-OptiPlex-5050:~/MyDev/betterauth-todo$ npm run dev
> betterauth-todo@0.1.0 dev
> next dev
▲ Next.js 16.1.4 (Turbopack)
- Local: http://localhost:3000
- Network: http://192.168.0.12:3000
- Environments: .env
✓ Starting...
✓ Ready in 723ms
GET / 200 in 622ms (compile: 327ms, render: 295ms)
[dotenv@17.2.3] injecting env (0) from .env -- tip: 🔐 prevent committing .env to code: https://dotenvx.com/precommit
[dotenv@17.2.3] injecting env (0) from .env -- tip: ✅ audit secrets and track compliance: https://dotenvx.com/ops
POST /api/auth/sign-up/email 200 in 2.5s (compile: 1405ms, render: 1062ms)
※ POST /api/auth/sign-up/email が 200 なのが確認できる
✅ (4) Neon DB の user table の確認
※ user テーブルにダミーで登録したデータが追加されています
無事に Neon DB / Drizzle ORM / Better Auth のセットアップが成功していることが確認できました。
🟠 6.ログインページの作成
✅ (1) ログインページのデザインを shadcn UI の中から選択します
採用するのはUIだけなので、ログイン処理の中身は後で自分で書きます。
今回は一番シンプルなログインページのデザインを採用します。
login-01 が良さそうです。
ターミナルで実行します
npx shadcn@latest add login-01
質問されます
✔ You need to create a components.json file to add components. Proceed? … yes
✔ Which color would you like to use as the base color? › Stone
※ お好みでどうぞ
自動的に以下のファイルがプロジェクトに追加されました
✔ Created 9 files:
- src/lib/utils.ts
- src/components/ui/button.tsx
- src/components/ui/card.tsx
- src/components/ui/input.tsx
- src/components/ui/label.tsx
- src/components/ui/separator.tsx
- src/components/ui/field.tsx
- src/app/login/page.tsx
- src/components/login-form.tsx
実行して http://localhost:3000/loginへアクセスします
✅ (2) ログインページのデザイン修正
自動生成したログインページは英語だったり、余分な機能(Login with Google)などがあるので修正します
✅ (3) 内部ロジック実装
入力項目のバリデーションは自作しません。
shadcn のReact Hooks Form と Zod を利用します。
shadcn の form コンポーネントを追加します。
npx shadcn@latest add form
合わせてトースト通知用の sonner も追加します。
npx shadcn@latest add sonner
トースト通知をすべてのページで表示できるように、ルートのlayout.tsxを修正します
/src/app/layout.tsx
return (
<html lang="jp">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
+ <Toaster />
</body>
</html>
);
入力項目のバリデーションを定義します。
合わせてエラーメッセージがデフォルトだと英語なので、日本語のメッセージを追加します。
const formSchema = z.object({
email: z.email("正しいメールアドレスの形式で入力してください"),
password: z
.string()
.min(1, "パスワードは必須です")
});
ログインフォームのSubmitで Better Auth クライアントSDKの SignIn を呼び出します。
import { signIn } from "@/lib/auth-client"; // Client SDK
async function onSubmit(values: z.infer<typeof formSchema>) {
// Better Auth のクライアントSDKを利用してsingIn
await signIn.email(
{
email: values.email,
password: values.password,
},
{
onSuccess: () => {
router.refresh();
router.push("/");
},
onError: (ctx) => {
toast.error(ctx.error.message);
},
},
);
}
===========================================
login-form.tsx 全体を表示(折りたたみ)
============================================
/src/components/login-form.tsx
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup } from "@/components/ui/field";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import { signIn } from "@/lib/auth-client"; // Client SDK
import { useState } from "react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
const formSchema = z.object({
email: z.email("正しいメールアドレスの形式で入力してください"),
password: z
.string()
.min(1, "パスワードは必須です")
});
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const [isLoading, setLoading] = useState(false);
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
// Better Auth のクライアントSDKを利用してsingIn
await signIn.email(
{
email: values.email,
password: values.password,
},
{
onSuccess: () => {
router.refresh();
router.push("/");
},
onError: (ctx) => {
toast.error("ログインに失敗しました: " + ctx.error.message);
},
},
);
setLoading(false);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Better Auth Todo アプリデモ</CardTitle>
<CardDescription>ログインしてください</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FieldGroup>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>メールアドレス</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>パスワード</FormLabel>
<FormControl>
<Input
placeholder="********"
{...field}
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"ログイン"
)}
</Button>
<FieldDescription className="text-center">
初めてのご利用ですか? <a href="/signup">アカウント作成</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
/src/app/login/page.tsx
import { LoginForm } from "@/components/login-form";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
<div className="mt-4 text-center text-sm text-muted-foreground">
<p>テスト用のアカウントを用意しています。</p>
<p className="font-mono mt-1">test@example.com / kyouhayuki</p>
<p className="font-mono mt-1">admin@test.com / admintarou</p>
</div>{" "}
</div>
</div>
);
}
🟠 7.アカウント登録ページの実装
✅ (1) サインアップページのデザインのベースを shadcn UI の中から選択します
signup-03 が良さそうです
ターミナルからインストールします
npx shadcn@latest add signup-03
✅ (2) サインアップページのデザイン修正
ログインページと同様に、余分な機能は削除し日本語に修正します
✅ (3) 内部ロジック実装
入力項目のバリデーションを定義します。
登録時なので、詳細なメッセージを表示します。
const formSchema = z.object({
email: z.email("正しいメールアドレスの形式で入力してください"),
userName: z.string().min(4, { message: "ユーザー名は4文字以上必要です" }),
password: z
.string()
.min(8, { message: "パスワードは8文字以上必要です" })
.max(50, { message: "パスワードは50文字以内にしてください" }),
});
サインアップフォームのSubmitで Better Auth クライアントSDKの SignUp を呼び出します。
import { signUp } from "@/lib/auth-client"; // Client SDK
async function onSubmit(values: z.infer<typeof formSchema>) {
const { error } = await signUp.email({
email: values.email,
password: values.password,
name: values.userName,
});
if (error) {
toast.error("登録に失敗しました: " + error.message);
setIsLoading(false);
return;
}
// 成功時
toast.success("登録が完了しました");
router.push("/");
}
===========================================
signup-form.tsx 全体を表示(折りたたみ)
============================================
/src/components/signup-form.tsx
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ArrowLeft, Loader2 } from "lucide-react";
import { signUp } from "@/lib/auth-client"; // Client SDK
const formSchema = z.object({
email: z.email("正しいメールアドレスの形式で入力してください"),
userName: z.string().min(4, { message: "ユーザー名は4文字以上必要です" }),
password: z
.string()
.min(8, { message: "パスワードは8文字以上必要です" })
.max(50, { message: "パスワードは50文字以内にしてください" }),
});
export function SignupForm({
className,
...props
}: React.ComponentProps<"div">) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
userName: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
const { error } = await signUp.email({
email: values.email,
password: values.password,
name: values.userName,
});
if (error) {
toast.error("登録に失敗しました: " + error.message);
setIsLoading(false);
return;
}
// 成功時
toast.success("登録が完了しました");
router.push("/");
setIsLoading(false);
}
function onCancel(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault(); // フォーム送信を防ぐ
e.stopPropagation(); // 親要素へのイベント伝播を防ぐ
router.push("/login");
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">アカウントの作成</CardTitle>
<CardDescription>利用者情報を登録してください。</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FieldGroup>
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem>
<FormLabel>ユーザー名</FormLabel>
<FormControl>
<Input placeholder="○山 太郎" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>メールアドレス</FormLabel>
<FormControl>
<Input placeholder="test@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>パスワード</FormLabel>
<FormControl>
<Input
placeholder="********"
{...field}
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 mt-6">
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"登録"
)}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={onCancel}
>
<ArrowLeft className="mr-2 size-4" />
ログイン画面に戻る
</Button>
</div>
</FieldGroup>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
/src/app/signup/page.tsx
import { SignupForm } from "@/components/signup-form";
export default function SignupPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<SignupForm />
</div>
</div>
);
}
🟠 8.Logoutボタンの作成
ログアウトボタンのクリックで Better Auth クライアントSDKの SignOut を呼び出します。
/src/components/logout.tsx
"use client";
import { signOut } from "@/lib/auth-client";
export function Logout() {
const handleLogout = async () => {
setLoading(true);
await signOut();
router.push("/login");
setLoading(false);
};
===========================================
logout.tsx 全体を表示(折りたたみ)
============================================
/src/components/logout.tsx
"use client";
import { signOut } from "@/lib/auth-client";
import { Button } from "./ui/button";
import { Loader2, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function Logout() {
const [isLoading, setLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setLoading(true);
await signOut();
router.push("/login");
setLoading(false);
};
return (
<Button variant="outline" onClick={handleLogout} disabled={isLoading}>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
ログアウト
<LogOut className="ml-2 size-4" />
</>
)}
</Button>
);
}
🟠 9.Todoアプリの作成(Model編)
ここからが本題のTodoアプリページの作成になります!
✅ (1) Todoを記録するスキーマを定義します
/src/db/schema.ts
// ----------------- Todoアプリ用のテーブル -----------------
export const todo = pgTable(
"todo",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
タスク: text("タスク").notNull(),
is完了: boolean("is完了").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("todo_userId_idx").on(table.userId)],
);
export const todoRelations = relations(todo, ({ one }) => ({
user: one(user, {
fields: [todo.userId],
references: [user.id],
}),
}));
export const schema = { user, session, account, verification, todo };
✅ (2) スキーマを変更を Neon DB へ反映する
npx drizzle-kit push
ターミナルで変更が反映されたことを確認します。
[✓] Pulling schema from database...
[✓] Changes applied
Neon DB に todo テーブルが追加されてます。
✅ (3) todo テーブル用のリポジトリを作成します
認証関連のDBアクセスは Better Auth がすべて自動でやってくれますが、自作するアプリ部分のDBアクセスはここで自作します。
/src/lib/todo.ts
- select ( getMyTodos ) 指定された userId の todoだけ取得
- insert ( addTodo ) todoの追加
- update ( toggleTodo ) todoの完了状態の更新
- delete ( deleteTodo ) todoの削除
import { db } from "@/db/drizzle";
import { todo } from "@/db/schema";
import { eq, and, asc, sql } from "drizzle-orm";
import { type InferSelectModel } from "drizzle-orm";
export type Todo = InferSelectModel<typeof todo>;
export const getMyTodos = async (userId: string): Promise<Todo[]> => {
const todos = await db
.select()
.from(todo)
.where(eq(todo.userId, userId))
.orderBy(asc(todo.is完了), asc(todo.createdAt));
return todos;
};
export const addTodo = async (userId: string, タスク: string) => {
await db.insert(todo).values({
userId: userId,
タスク: タスク,
});
};
export const toggleTodo = async (userId: string, id: string) => {
await db
.update(todo)
.set({ is完了: sql`NOT ${todo.is完了}` })
.where(and(eq(todo.id, id), eq(todo.userId, userId)));
};
export const deleteTodo = async (userId: string, id: string) => {
await db.delete(todo).where(
and(
eq(todo.id, id),
eq(todo.userId, userId),
),
);
};
🟠 10.Todoアプリの作成(認証関連編)
Better Auth を利用した認証の仕組みは作成済みです。
その仕組みをアプリに組み込みます。
✅ (1) proxy.ts の作成
./proxy.ts ファイルを作成します
ログインしていない場合は、公開ページのみ表示します。
逆にログインしている場合は、ログインページを表示させません。
./proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 公開パスの定義
const publicPaths = ["/login", "/signup"];
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
const session = await auth.api.getSession({
headers: await headers(),
});
// -------------------------------------------------------------
// パターンの分岐
// -------------------------------------------------------------
// ケース1:未ログイン + 公開パス以外にアクセスしようとした
if (!session && !isPublicPath) {
return NextResponse.redirect(new URL("/login", request.url));
}
// ケース2:ログイン済み + ログイン・登録系ページにアクセスしようとした(逆流防止)
if (session && isPublicPath) {
// 認証済みルート((protected)/page.tsx すなわち "/")へリダイレクト
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
/*
* 以下のパス以外すべてに proxy を適用する:
* 1. api (API routes)
* 2. _next/static (static files)
* 3. _next/image (image optimization files)
* 4. favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
✅ (2) 各サーバーコンポーネントから呼び出す認証ガードの作成
@/lib/auth はサーバーコンポーネント(RSC)用です。
(クライアントコンポーネント用には @/lib/auth-client を使用します。)
認証が必要なページでは、サーバー側で事前にセッションを確認し、未認証の場合はレンダリング(描画)が行われる前に login ページへリダイレクトさせましょう。
クライアントコンポーネントだけでガードするのは危険です。
useEffect 等で画面遷移させるだけでは不十分です。
ブラウザでの表示上は隠せても、裏側で通信されるデータ(ペイロード)には機密情報が含まれてしまい、情報漏えいにつながるリスクがあります。
/src/lib/auth-guard.ts
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
// 認証ガード
export async function requireSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/login");
}
return session;
}
🟠 11.Todoアプリの作成(ルートページの作成)
ルートページ(実際にTodoの一覧が表示されるページ)は 認証者のみ表示するページです。
後からでもそれが分かるように、認証者専用であることをフォルダ構成で示します。
✅ (1) (protected) フォルダの作成
/src/app 直下に (protected) フォルダを作成します。
/src/app 直下に存在した page.tsx は削除します。
✅ (2) page.tsx の作成
/src/app/(protected)/page.tsx ファイルを作成します。
レンダリング前に作成した 認証ガード を呼び出します。
// 認証ガード
const session = await requireSession();
また、認証している人に紐付いた Todo の一覧も Todoのリポジトリを呼び出し、ここでレンダリング前に取得します。
// ログイン者のTodoを表示
const todos = await getMyTodos(userId);
===========================================
page.tsx 全体を表示(折りたたみ)
============================================
/src/app/(protected)/page.tsx
import { getMyTodos } from "@/lib/todo";
import { TodoApp } from "@/components/TodoApp";
import { requireSession } from "@/lib/auth-guard";
import { Logout } from "@/components/logout";
// AWSへデプロイした時にこのページがダイナミックレンダリングなことを明示する
export const dynamic = "force-dynamic";
export default async function Home() {
// 認証ガード
const session = await requireSession();
const { id: userId, name: userName } = session.user;
// ログイン者のTodoを表示
const todos = await getMyTodos(userId);
return (
<main className="min-h-screen flex flex-col items-center justify-center p-4 bg-slate-50">
<div className="w-full max-w-md space-y-4">
<div className="flex items-center justify-between px-2">
<div className="space-y-0.5">
<h2 className="text-xl font-bold tracking-tight text-slate-900">
My Tasks
</h2>
<p className="text-sm text-slate-500">
{userName} さんのワークスペース
</p>
</div>
<div className="scale-90 origin-right">
<Logout />
</div>
</div>
<TodoApp todos={todos} />
</div>
</main>
);
}
✅ (3) コントローラー(サーバーアクション) actions.tsx の作成
ユーザーがTodoの追加ボタンを押したら、一覧に表示されるTodoも最新化される必要があります。
この一連の流れを コントローラーの役割を担うサーバーアクション として実装します。
ただし、ユーザーIDを引数として受け取ると、クライアントから改ざんされたユーザーIDを渡される可能性があるため、必ずサーバー側でセッションからユーザーIDを取得するようにします。
/src/app/(protected)/actions.tsx ファイルを作成します。
ユーザーIDをセッションから取得します
const getUserId = async () => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.id) {
throw new Error("認証切れ、またはログインしていません");
}
return session.user.id;
};
Todoが追加された場合、ルートページのキャッシュ破棄し、最新のTodo一覧を表示させます。
Todoの編集(完了/未完了)、削除も同様です。
import { addTodo } from "@/lib/todo";
export const addTodoAction = async (title: string) => {
// DB操作
const userId = await getUserId();
await addTodo(userId, title);
// UI操作(ルートページのキャッシュを破棄し、最新のTodo一覧を表示させる)
revalidatePath("/");
};
===========================================
actions.tsx 全体を表示(折りたたみ)
============================================
/src/app/(protected)/actions.tsx
"use server";
import { addTodo, deleteTodo, toggleTodo } from "@/lib/todo";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
// ユーザーIDをPropsとして受け取ると、クライアントから改ざんされた
// ユーザーIDが渡される可能性があるため、必ずサーバー側でセッションから
// 取得するようにしています。
const getUserId = async () => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user?.id) {
throw new Error("認証切れ、またはログインしていません");
}
return session.user.id;
};
export const addTodoAction = async (title: string) => {
// DB操作
const userId = await getUserId();
await addTodo(userId, title);
// UI操作(ルートページのキャッシュを破棄し、最新のTodo一覧を表示させる)
revalidatePath("/");
};
export const toggleTodoAction = async (id: string) => {
// DB操作
const userId = await getUserId();
await toggleTodo(userId, id);
// UI操作(ルートページのキャッシュを破棄し、最新のTodo一覧を表示させる)
revalidatePath("/");
};
export const deleteTodoAction = async (id: string) => {
// DB操作
const userId = await getUserId();
await deleteTodo(userId, id);
// UI操作(ルートページのキャッシュを破棄し、最新のTodo一覧を表示させる)
revalidatePath("/");
};
🟠 12.Todoアプリの作成(コンポーネントの作成)
Todoアプリは次の3種類のコンポーネントに分割できます。
① TodoApp.tsx :
Todoの追加機能+一覧表示(②)
② TodoList.tsx :
Todoの一覧(③の一覧)を表示するコンポーネント
③ TodoItem.tsx :
1行のTodoを表示するコンポーネント(完了+削除操作あり)
✅ (1) TodoApp.tsx の作成
/src/components/TodoApp.tsx
Todoが追加されたときのアクションを記述します。
登録は先に作ったコントローラーの addTodoAction を呼び出します。
const handleAdd = async () => {
const title = newTodo.trim();
if (title === "") return;
try {
await addTodoAction(title);
toast.success("タスクを追加しました");
setNewTodo("");
} catch {
toast.error("追加に失敗しました☠");
}
};
Todoの完了や削除操作を実際に呼び出すのは ③TodoItem ですが、処理の実装が散らばると可読性が下がるのでここでまとめて記述し、Propsで下流のコンポーネントへ引き渡します
const handleToggle = async (id: string) => {
try {
const targetTodo = todos.find((t) => t.id === id);
await toggleTodoAction(id);
if (targetTodo && !targetTodo.is完了) {
toast.success("タスクを完了しました!🎉");
} else {
toast.info("タスクを未完了に戻しました");
}
} catch {
toast.error("更新に失敗しました☠");
}
};
完了したタスクを表示するかしないか、チェックボックスとuseState、Todoリストへのフィルターで制御します。
const [showCompleted, setShowCompleted] = useState(false);
const filteredTodos = showCompleted
? todos
: todos.filter((todo) => !todo.is完了);
===========================================
TodoApp.tsx 全体を表示(折りたたみ)
============================================
/src/components/TodoApp.tsx
"use client";
import type React from "react";
import { useState } from "react";
import { todo } from "@/db/schema";
import { type InferSelectModel } from "drizzle-orm";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
addTodoAction,
toggleTodoAction,
deleteTodoAction,
} from "@/app/(protected)/actions";
import { TodoList } from "./TodoList";
import { toast } from "sonner";
type Todo = InferSelectModel<typeof todo>;
type PropsType = {
todos: Todo[];
};
export const TodoApp = ({ todos }: PropsType) => {
const [newTodo, setNewTodo] = useState("");
const [showCompleted, setShowCompleted] = useState(false);
// --- アクション ---
const handleAdd = async () => {
const title = newTodo.trim();
if (title === "") return;
try {
await addTodoAction(title);
toast.success("タスクを追加しました");
setNewTodo("");
} catch {
toast.error("追加に失敗しました☠");
}
};
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") await handleAdd();
};
const handleToggle = async (id: string) => {
try {
const targetTodo = todos.find((t) => t.id === id);
await toggleTodoAction(id);
if (targetTodo && !targetTodo.is完了) {
toast.success("タスクを完了しました!🎉");
} else {
toast.info("タスクを未完了に戻しました");
}
} catch {
toast.error("更新に失敗しました☠");
}
};
const handleDelete = async (id: string) => {
try {
const targetTodo = todos.find((t) => t.id === id);
await deleteTodoAction(id);
toast("タスクを削除しました", {
description: targetTodo ? `「${targetTodo.タスク}」` : undefined,
action: {
label: "閉じる",
onClick: () => {}, // Undo機能などをつける場合はここに実装
},
});
} catch {
toast.error("削除に失敗しました☠");
}
};
// 「完了したタスクも表示」をチェック/チェックオフ
const filteredTodos = showCompleted
? todos
: todos.filter((todo) => !todo.is完了);
const completedCount = todos.filter((todo) => todo.is完了).length;
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<p className="text-sm text-muted-foreground text-center">
{completedCount} / {todos.length} 完了
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* 入力エリア */}
<div className="flex gap-2">
<Input
type="text"
placeholder="新しいタスクを入力..."
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button onClick={handleAdd} size="icon">
<Plus className="h-4 w-4" />
<span className="sr-only">タスクを追加</span>
</Button>
</div>
{/* フィルター操作 */}
<div className="flex items-center gap-2">
<Checkbox
id="showCompleted"
checked={showCompleted}
onCheckedChange={(checked) => setShowCompleted(checked === true)}
/>
<label
htmlFor="showCompleted"
className="text-sm text-muted-foreground cursor-pointer"
>
完了したタスクも表示
</label>
</div>
{/* ★ リスト表示 */}
<TodoList
todos={filteredTodos}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</CardContent>
</Card>
);
};
✅ (2) TodoList.tsx の作成
/src/components/TodoList.tsx
受け取ったTodosを mapで展開して下流の TodoItem へ渡します。
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
===========================================
TodoList.tsx 全体を表示(折りたたみ)
============================================
/src/components/TodoList.tsx
import { todo } from "@/db/schema";
import { type InferSelectModel } from "drizzle-orm";
import { TodoItem } from "./TodoItem";
type Todo = InferSelectModel<typeof todo>;
type Props = {
// 表示用のTodoの配列情報
todos: Todo[];
// 完了チェックボックスをチェックされたときの動作内容
onToggle: (id: string) => void;
// 削除アイコンをクリックされたときの動作内容
onDelete: (id: string) => void;
};
export const TodoList = ({ todos, onToggle, onDelete }: Props) => {
if (todos.length === 0) {
return (
<p className="text-center text-muted-foreground py-4">
タスクがありません
</p>
);
}
return (
<div className="space-y-2">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</div>
);
};
✅ (3) TodoItem.tsx の作成
/src/components/TodoItem.tsx
受け取ったTodo1行分を表示します。
===========================================
TodoItem.tsx 全体を表示(折りたたみ)
============================================
/src/components/TodoItem.tsx
import { todo } from "@/db/schema";
import { type InferSelectModel } from "drizzle-orm";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
type Todo = InferSelectModel<typeof todo>;
type Props = {
// 表示用のTodoの情報
todo: Todo;
// 完了チェックボックスをチェックされたときの動作内容
onToggle: (id: string) => void;
// 削除アイコンをクリックされたときの動作内容
onDelete: (id: string) => void;
};
export const TodoItem = ({ todo, onToggle, onDelete }: Props) => {
return (
<div
className={cn(
"flex items-center gap-3 p-3 rounded-lg border bg-card transition-all",
todo.is完了 && "opacity-60",
)}
>
<Checkbox
checked={todo.is完了}
onCheckedChange={() => onToggle(todo.id)}
aria-label={`${todo.タスク}を${todo.is完了 ? "未完了" : "完了"}にする`}
/>
<span
className={cn(
"flex-1 text-sm",
todo.is完了 && "line-through text-muted-foreground",
)}
>
{todo.タスク}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(todo.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">削除</span>
</Button>
</div>
);
};
🟠 13.AWS Amplify へデプロイする場合の注意点
✅ .env で定義した環境変数をAWS環境へ追加します。
| 環境変数 | 内容 |
|---|---|
| BETTER_AUTH_SECRET | Better Auth インストール時に設定した値 |
| DATABASE_URL | Neon DB の接続文字列 |
| NEXT_PUBLIC_BETTER_AUTH_URL | デプロイしたアプリのURL |
✅ 「ビルドの設定」で環境変数を読み込むように設定します。
amplify.yml
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
# .env.production を作成 (既存があれば上書き)
- echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" > .env.production
- echo "DATABASE_URL=$DATABASE_URL" >> .env.production
- echo "BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL" >> .env.production
- echo "NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL" >> .env.production
build:
commands:
- npm run build
environmentVariables:
NEXT_PUBLIC_BETTER_AUTH_URL: ${NEXT_PUBLIC_BETTER_AUTH_URL}
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- .npm/**/*
🟠 14.まとめ
✅ 各種バージョン
| アプリケーション | バージョン |
|---|---|
| next.js | @16.1.4 |
| react | @19.2.3 |
| tailwindcss | @4 |
| better auth | @1.4.17 |
| drizzle-orm | @0.45.1 |
| zod | @4.3.6 |
✅ コード全文を公開してます














