0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

2026年 React / Next.js で認証機能あり Todoアプリ構築 ハンズオン(Better Auth / Drizzle ORM / Neon DB)

Last updated at Posted at 2026-01-26

🟠 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)**が入る可能性もあります。実装の際は必ず公式ドキュメントも併せてご確認ください。

アプリのイメージ

✅ ログイン
image.png

✅ Todoページ
image.png

👉 デモサイトを表示

🟠 1.Next.jsのプロジェクト作成

公式のコマンド

npx create-next-app@latest [project-name] [options]

参考

npx create-next-app@latest betterauth-todo --yes

🟠 2.利用するDB環境の構築

今回は Neon DB を利用します。

✅ (1) Neon DB に新しいプロジェクトを作ります
image.png

✅ (2) 接続文字列を取得しておきます
image.png

🟠 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の公式ページにシークレットの生成ボタンがあります。

image.png

✅ (3) /src/lib/auth.ts ファイルを作成

Better Authの基本設定になります。

利用する Better Auth の機能や 利用するORMなどによって記述内容は変わります。
ここでは以下の条件の設定例を示してます。

  1. 認証情報は drizzle 経由の Postgres へ格納する
  2. 認証はemail + パスワードのみとする
  3. セッションの有効期限を設定する
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 へスキーマが反映されたことを確認します

image.png

✅ (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) ボタンクリック時のコンソール出力

image.png

※ 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 の確認

image.png

※ user テーブルにダミーで登録したデータが追加されています

無事に Neon DB / Drizzle ORM / Better Auth のセットアップが成功していることが確認できました。

🟠 6.ログインページの作成

✅ (1) ログインページのデザインを shadcn UI の中から選択します

採用するのはUIだけなので、ログイン処理の中身は後で自分で書きます。

今回は一番シンプルなログインページのデザインを採用します。
login-01 が良さそうです。

image.png

ターミナルで実行します

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へアクセスします

image.png

✅ (2) ログインページのデザイン修正

自動生成したログインページは英語だったり、余分な機能(Login with Google)などがあるので修正します

image.png

✅ (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 が良さそうです

image.png

ターミナルからインストールします

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 テーブルが追加されてます。

image.png

✅ (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環境へ追加します。

image.png

環境変数 内容
BETTER_AUTH_SECRET Better Auth インストール時に設定した値
DATABASE_URL Neon DB の接続文字列
NEXT_PUBLIC_BETTER_AUTH_URL デプロイしたアプリのURL

✅ 「ビルドの設定」で環境変数を読み込むように設定します。

image.png

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

✅ コード全文を公開してます

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?