Next.js: 最終プロジェクト: 認証とセットアップ

最終更新:2026-08-26

完全な SaaS プロジェクトを構築することは建物を建てるようなものです — まず足場を組んで構造フレームワークを立て、次に電気・配管システム(データベース)を設計し、最後にドアロック(認証システム)を取り付けます。

1. 学習内容



2. あるフルスタックエンジニアの実話

(1) 課題:ゼロから SaaS プラットフォームを構築する混沌としたスタート

Alice は 50 人のスタートアップで働くフルスタックエンジニアです。会社は社内プロジェクトコラボレーションプラットフォーム「TaskFlow」を開発することを決定し、10,000 人のユーザーにサービスを提供し、各ユーザーが 200 以上のタスクを管理します。Alice は手動でプロジェクト構造をセットアップしようとしましたが — ルーティングが混乱し、データベースマイグレーションが頻繁に失敗し、提案した 3 つの認証ソリューションはすべて却下されました。チームは環境設定に 1 日 4 時間を無駄にし、プロジェクトは予定より 2 週間遅れていました。

(2) スキャフォールディング + 認証フレームワークを使用したソリューション

create-next-app によるワンクリック初期化 + Prisma ORM による宣言的モデリング + Auth.js のすぐに使える認証。

BASH
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
npx prisma init --datasource-provider postgresql
npx auth add

3 行のコードで初期化作業の 80% を処理します。

(3) 成果

項目 手動セットアップ TaskFlow ソリューション
初期化時間 2 日 2 時間
認証ソリューション開発 1 週間(自作 JWT) 30 分(Auth.js 統合)
データベースマイグレーション 手動 SQL スクリプト Prisma Migrate(宣言的)
プロジェクト構造ガイドライン チームごとにバラバラ App Router: 規約ベースルーティング
保守性 低い(型安全性なし) 高い(TypeScript + Prisma 型)


3. プロジェクトの初期化と設定

(1) create-next-app スキャフォールディングツールの選択

100%
graph LR
    A[create-next-app] --> B[TypeScript]
    A --> C[Tailwind CSS]
    A --> D[ESLint]
    A --> E[App Router]
    A --> F[src/ ディレクトリ]
    A --> G[@/ エイリアス]

    style A fill:#cce5ff
    style B fill:#d4edda
    style C fill:#d4edda
オプション 説明
TypeScript はい 型安全性 — 本番環境に必須
ESLint はい コーディング規約
Tailwind CSS はい shadcn/ui と連携
src/ ディレクトリ はい app/ と分離して明確な構造に
App Router はい Next.js 16 デフォルト
インポートエイリアス @/* 簡潔なインポートパス

▶ サンプル: TaskFlow プロジェクトの初期化

💻 出力:

TEXT 📖 参照専用
データフローとコンポーネント構造の図。
BASH
# 1. プロジェクトの作成
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

# 2. ディレクトリに移動
cd taskflow

# 3. shadcn/ui のインストール
npx shadcn@latest init -d

# 4. shadcn/ui 共通コンポーネントの追加
npx shadcn@latest add button card input label select dialog dropdown-menu avatar badge separator

# 5. 開発サーバーの起動
npm run dev
TEXT 📖 参照専用
✔ プロジェクトが作成されました: taskflow/
✔ shadcn/ui が正常に初期化されました
✔ コンポーネント button がインストールされました
✔ コンポーネント card がインストールされました
✔ コンポーネント input がインストールされました
✔ 合計 7 つのコンポーネントがインストールされました
💻 出力:

TEXT 📖 参照専用
✔ プロジェクトが作成されました: taskflow/
✔ shadcn/ui が正常に初期化されました
✔ コンポーネント button がインストールされました
✔ コンポーネント card がインストールされました
✔ コンポーネント input がインストールされました
✔ 合計 7 つのコンポーネントがインストールされました

(2) プロジェクトディレクトリ構造

TEXT 📖 参照専用
taskflow/
├── src/
│   ├── app/                    # App Router ページ
│   │   ├── (auth)/            # 認証関連ルートグループ
│   │   ├── (dashboard)/       # ダッシュボードルートグループ
│   │   ├── api/               # API ルート
│   │   ├── layout.tsx         # ルートレイアウト
│   │   └── page.tsx           # ホーム
│   ├── components/            # 共有コンポーネント
│   │   ├── ui/               # shadcn/ui コンポーネント
│   │   └── forms/            # フォームコンポーネント
│   ├── lib/                  # ユーティリティ関数
│   │   ├── auth.ts           # Auth.js 設定
│   │   ├── db.ts             # Prisma クライアント
│   │   └── utils.ts          # 汎用ツール
│   ├── prisma/               # Prisma スキーマ + マイグレーション
│   │   ├── schema.prisma
│   │   └── seed.ts
│   └── middleware.ts         # ルート保護ミドルウェア
├── public/                   # 静的リソース
├── next.config.ts            # Next.js 設定
├── tailwind.config.ts        # Tailwind 設定
└── package.json


4. Prisma スキーマの 5 モデル設計

(1) データモデル関係図

100%
graph TB
    U[User] -->|所属| O[Organization]
    O -->|複数持つ| P[Project]
    P -->|複数持つ| T[Task]
    T -->|複数持つ| C[Comment]
    U -->|担当者| T
    U -->|作成者| C
    U -->|作成者| P

    style U fill:#cce5ff
    style O fill:#d4edda
    style P fill:#ffeeba
    style T fill:#f8d7da
    style C fill:#d6d8db

(2) 列挙型の定義

列挙型名 説明
Role OWNER / ADMIN / MEMBER / VIEWER 組織内の役割
ProjectStatus ACTIVE / ARCHIVED / COMPLETED プロジェクトステータス
TaskStatus TODO / IN_PROGRESS / DONE タスクステータス
TaskPriority LOW / MEDIUM / HIGH / URGENT タスク優先度

(3) 完全な Prisma スキーマ

PRISMA
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

enum ProjectStatus {
  ACTIVE
  ARCHIVED
  COMPLETED
}

enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}

enum TaskPriority {
  LOW
  MEDIUM
  HIGH
  URGENT
}

model User {
  id             String   @id @default(cuid())
  name           String?
  email          String   @unique
  emailVerified  DateTime?
  image          String?
  passwordHash   String?
  organizationId String?
  organization   Organization? @relation(fields: [organizationId], references: [id])
  role           Role     @default(MEMBER)
  projects       Project[]
  assignedTasks  Task[]   @relation("TaskAssignee")
  comments       Comment[]
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  accounts  Account[]
  sessions  Session[]
}

model Organization {
  id        String    @id @default(cuid())
  name      String
  slug      String    @unique
  inviteCode String   @unique @default(cuid())
  logo      String?
  users     User[]
  projects  Project[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Project {
  id          String        @id @default(cuid())
  name        String
  description String?
  status      ProjectStatus @default(ACTIVE)
  organizationId String
  organization Organization @relation(fields: [organizationId], references: [id])
  creatorId   String
  creator     User          @relation(fields: [creatorId], references: [id])
  tasks       Task[]
  color       String        @default("#3b82f6")
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
}

model Task {
  id          String       @id @default(cuid())
  title       String
  description String?
  status      TaskStatus   @default(TODO)
  priority    TaskPriority @default(MEDIUM)
  projectId   String
  project     Project      @relation(fields: [projectId], references: [id])
  assigneeId  String?
  assignee    User?        @relation("TaskAssignee", fields: [assigneeId], references: [id])
  creatorId   String
  creator     User         @relation(fields: [creatorId], references: [id])
  order       Int          @default(0)
  dueDate     DateTime?
  attachments Attachment[]
  comments    Comment[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  taskId    String
  task      Task     @relation(fields: [taskId], references: [id])
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Attachment {
  id        String   @id @default(cuid())
  fileName  String
  fileUrl   String
  fileSize  Int
  mimeType  String
  taskId    String
  task      Task     @relation(fields: [taskId], references: [id])
  uploaderId String
  uploader  User     @relation(fields: [uploaderId], references: [id])
  createdAt DateTime @default(now())
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

▶ サンプル: データベースマイグレーションの実行

BASH
# 1. マイグレーションの作成
npx prisma migrate dev --name init

# 2. Prisma クライアントの生成
npx prisma generate

# 3. データベースの表示
npx prisma studio
TEXT 📖 参照専用
✔ Prisma Client (v6.x) が .\node_modules\@prisma\client に生成されました
✔ データベースが Prisma スキーマと同期されました
✔ Prisma Studio が http://localhost:5555 で開かれました


5. Auth.js 認証統合

(1) 認証スキームの比較

ソリューション 利点 欠点 適用シナリオ
Auth.js v5 複数プロバイダー、セッション内蔵、型安全 中程度の学習曲線 柔軟な認証が必要なプロジェクト
Clerk すぐに使える UI、設定不要 有料、ベンダーロックイン 迅速なプロトタイピング
Supabase Auth 寛大な無料枠、データベースと統合 Supabase エコシステムとの統合が必要 フルマネージドソリューション
自作 JWT 完全な制御 セキュリティリスクが高い、開発コストが高い 推奨されません

(2) Auth.js 設定

TYPESCRIPT
// src/lib/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import Google from "next-auth/providers/google"
import { compare } from "bcryptjs"
import { prisma } from "./db"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "メールアドレス", type: "email" },
        password: { label: "パスワード", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        })

        if (!user || !user.passwordHash) {
          return null
        }

        const isValid = await compare(
          credentials.password as string,
          user.passwordHash
        )

        if (!isValid) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string
      }
      return session
    },
  },
  pages: {
    signIn: "/auth/signin",
    error: "/auth/error",
  },
  session: {
    strategy: "jwt",
  },
})

▶ サンプル: Auth.js API ルート

TYPESCRIPT
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth"

export const { GET, POST } = handlers
💻 出力:

TEXT 📖 参照専用
TypeScript コードが正常に実行されました。
TYPESCRIPT
// src/app/api/auth/signup/route.ts
import { NextResponse } from "next/server"
import { hash } from "bcryptjs"
import { prisma } from "@/lib/db"

export async function POST(request: Request) {
  try {
    const { name, email, password, inviteCode } = await request.json()

    const existingUser = await prisma.user.findUnique({
      where: { email },
    })

    if (existingUser) {
      return NextResponse.json(
        { error: "このメールアドレスは既に登録されています" },
        { status: 400 }
      )
    }

    const passwordHash = await hash(password, 12)

    let organizationId: string | null = null
    let role: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER" = "MEMBER"

    if (inviteCode) {
      const org = await prisma.organization.findUnique({
        where: { inviteCode },
      })
      if (org) {
        organizationId = org.id
      }
    }

    const user = await prisma.user.create({
      data: {
        name,
        email,
        passwordHash,
        organizationId,
        role: organizationId ? "MEMBER" : "OWNER",
      },
    })

    if (!organizationId) {
      const org = await prisma.organization.create({
        data: {
          name: `${name} の組織`,
          slug: email.split("@")[0],
          users: { connect: { id: user.id } },
        },
      })
      await prisma.user.update({
        where: { id: user.id },
        data: { organizationId: org.id, role: "OWNER" },
      })
    }

    return NextResponse.json(
      { message: "ユーザーが正常に作成されました" },
      { status: 201 }
    )
  } catch (error) {
    return NextResponse.json(
      { error: "問題が発生しました" },
      { status: 500 }
    )
  }
}

(3) 組織招待コードシステム

TYPESCRIPT
// src/lib/invite.ts
import { prisma } from "./db"

export async function generateInviteCode(organizationId: string) {
  const org = await prisma.organization.update({
    where: { id: organizationId },
    data: { inviteCode: crypto.randomUUID().slice(0, 8) },
  })
  return org.inviteCode
}

export async function validateInviteCode(code: string) {
  // TKFLW-XXXXXX 形式
  const fullCode = `TKFLW-${code.toUpperCase()}`
  const org = await prisma.organization.findUnique({
    where: { inviteCode: fullCode },
  })
  return org
}

▶ サンプル: ログインと登録ページ

💻 出力:

TEXT 📖 参照専用
データベースレコードを更新します。
TSX
// src/app/(auth)/auth/signin/page.tsx
import { AuthCard } from "@/components/auth/auth-card"
import { SignInForm } from "@/components/auth/sign-in-form"

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <AuthCard
        title="TaskFlow にサインイン"
        description="続行するには認証情報を入力してください"
      >
        <SignInForm />
      </AuthCard>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
SignInPage コンポーネントの UI をレンダリングします。
TSX
// src/components/auth/sign-in-form.tsx
"use client"

import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent } from "@/components/ui/card"

export function SignInForm() {
  const router = useRouter()
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setIsLoading(true)
    setError(null)

    const formData = new FormData(event.currentTarget)

    const result = await signIn("credentials", {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
      redirect: false,
    })

    if (result?.error) {
      setError("メールアドレスまたはパスワードが無効です")
      setIsLoading(false)
      return
    }

    router.push("/dashboard")
    router.refresh()
  }

  return (
    <Card>
      <CardContent className="pt-6">
        <form onSubmit={onSubmit} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">メールアドレス</Label>
            <Input
              id="email"
              name="email"
              type="email"
              placeholder="alice@example.com"
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">パスワード</Label>
            <Input
              id="password"
              name="password"
              type="password"
              placeholder="パスワードを入力してください"
              required
            />
          </div>
          {error && (
            <p className="text-sm text-red-500">{error}</p>
          )}
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? "サインイン中..." : "サインイン"}
          </Button>
          <div className="relative">
            <div className="absolute inset-0 flex items-center">
              <span className="w-full border-t" />
            </div>
            <div className="relative flex justify-center text-xs uppercase">
              <span className="bg-background px-2 text-muted-foreground">
                または
              </span>
            </div>
          </div>
          <Button
            type="button"
            variant="outline"
            className="w-full"
            onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
          >
            <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
              <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
              <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
              <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
              <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
            </svg>
            Google でサインイン
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}


6. ミドルウェアルート保護

(1) ミドルウェアワークフロー

100%
graph LR
    A[リクエスト] --> B{ミドルウェア}
    B -->|公開パス| C[許可]
    B -->|保護パス| D{セッションあり?}
    D -->|はい| C
    D -->|いいえ| E[/auth/signin にリダイレクト]
    B -->|API ルート| F{有効なトークン?}
    F -->|はい| G[転送]
    F -->|いいえ| H[401 Unauthorized]

    style B fill:#ffeeba
    style C fill:#d4edda
    style E fill:#f8d7da

(2) ミドルウェア実装

TYPESCRIPT
// src/middleware.ts
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { nextUrl } = req
  const isLoggedIn = !!req.auth
  const isApiRoute = nextUrl.pathname.startsWith("/api")
  const isAuthRoute = nextUrl.pathname.startsWith("/auth")
  const isDashboardRoute = nextUrl.pathname.startsWith("/dashboard")
  const isPublicRoute =
    nextUrl.pathname === "/" ||
    nextUrl.pathname.startsWith("/_next") ||
    nextUrl.pathname.startsWith("/static") ||
    nextUrl.pathname === "/api/auth/signup"

  if (isApiRoute && !isPublicRoute && !isLoggedIn) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }

  if (isDashboardRoute && !isLoggedIn) {
    return NextResponse.redirect(new URL("/auth/signin", nextUrl))
  }

  if (isAuthRoute && isLoggedIn) {
    return NextResponse.redirect(new URL("/dashboard", nextUrl))
  }

  return NextResponse.next()
})

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
}

▶ サンプル: ミドルウェアルート保護のテスト

BASH
# 保護ルートへのアクセス(未ログイン)
curl -v http://localhost:3000/dashboard 2>&1 | grep Location

# API ルートへのアクセス(未ログイン)
curl -v http://localhost:3000/api/projects 2>&1
TEXT 📖 参照専用
< Location: http://localhost:3000/auth/signin
< HTTP/1.1 302 Found

{"error":"Unauthorized"}

(3) Matcher 設定戦略

パターン マッチするパス 除外パス
/((?!_next/static).*) 全ルート 静的リソース
/((?!api/auth).*) 全ルート 認証 API
`/((?!_next/static _next/image).*)` 全ルート


7. データベースシーディング

(1) シーディング戦略

100%
graph TB
    A[seed.ts] --> B[組織の作成]
    B --> C[ユーザー x5 の作成]
    C --> D[プロジェクト x5 の作成]
    D --> E[タスク x50 の作成]
    E --> F[コメント x40 の作成]
    F --> G[添付ファイル x10 の作成]

    style A fill:#cce5ff
    style G fill:#d4edda

▶ サンプル: 完全なシーディングスクリプト

💻 出力:

TEXT 📖 参照専用
シードフロー図: 組織の作成 → ユーザー → プロジェクト → タスク → コメント。
TYPESCRIPT
// src/prisma/seed.ts
import { PrismaClient, Role, TaskStatus, TaskPriority } from "@prisma/client"
import { hash } from "bcryptjs"

const prisma = new PrismaClient()

async function main() {
  console.log("🌱 データベースをシード中...")

  const passwordHash = await hash("password123", 12)

  const org = await prisma.organization.create({
    data: {
      name: "Acme Corp",
      slug: "acme-corp",
      inviteCode: "TKFLW-ACMECORP",
    },
  })

  const users = await Promise.all([
    prisma.user.create({
      data: {
        name: "Alice Wang",
        email: "alice@acme.com",
        passwordHash,
        organizationId: org.id,
        role: "OWNER",
        image: "https://api.dicebear.com/7.x/avataaars/svg?seed=alice",
      },
    }),
    prisma.user.create({
      data: {
        name: "Bob Chen",
        email: "bob@acme.com",
        passwordHash,
        organizationId: org.id,
        role: "ADMIN",
        image: "https://api.dicebear.com/7.x/avataaars/svg?seed=bob",
      },
    }),
    prisma.user.create({
      data: {
        name: "Charlie Liu",
        email: "charlie@acme.com",
        passwordHash,
        organizationId: org.id,
        role: "MEMBER",
        image: "https://api.dicebear.com/7.x/avataaars/svg?seed=charlie",
      },
    }),
    prisma.user.create({
      data: {
        name: "Diana Park",
        email: "diana@acme.com",
        passwordHash,
        organizationId: org.id,
        role: "MEMBER",
        image: "https://api.dicebear.com/7.x/avataaars/svg?seed=diana",
      },
    }),
    prisma.user.create({
      data: {
        name: "Eve Zhang",
        email: "eve@acme.com",
        passwordHash,
        organizationId: org.id,
        role: "VIEWER",
        image: "https://api.dicebear.com/7.x/avataaars/svg?seed=eve",
      },
    }),
  ])

  const projectData = [
    { name: "ウェブサイトリニューアル", description: "モダンな UI で会社のウェブサイトを再設計", color: "#3b82f6" },
    { name: "モバイルアプリ v2", description: "モバイルアプリケーションのバージョン 2", color: "#10b981" },
    { name: "API 統合", description: "サードパーティ API 統合", color: "#f59e0b" },
    { name: "データ移行", description: "レガシーシステムからのデータ移行", color: "#ef4444" },
    { name: "セキュリティ監査", description: "Q3 セキュリティ監査とコンプライアンス", color: "#8b5cf6" },
  ]

  const projects = await Promise.all(
    projectData.map((p) =>
      prisma.project.create({
        data: {
          ...p,
          organizationId: org.id,
          creatorId: users[0].id,
        },
      })
    )
  )

  const taskTitles = [
    "CI/CD パイプラインのセットアップ", "デザインシステムコンポーネント", "ユニットテストの作成",
    "ユーザー認証の実装", "データベーススキーマの作成", "API ドキュメント",
    "パフォーマンス最適化", "アクセシビリティ監査", "モバイルレスポンシブレイアウト",
    "エラーハンドリングミドルウェア", "データバリデーション", "検索機能",
    "通知システム", "ファイルアップロード機能", "ユーザーダッシュボード",
    "管理パネル", "レポートモジュール", "メールテンプレート",
    "WebSocket 統合", "キャッシュレイヤー", "レート制限",
    "ログシステム", "バックアップ戦略", "監視セットアップ",
    "負荷テスト",
  ]

  const statuses: TaskStatus[] = ["TODO", "IN_PROGRESS", "DONE"]
  const priorities: TaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"]

  for (const project of projects.slice(0, 3)) {
    const taskCount = project.name === "ウェブサイトリニューアル" ? 20 : 15

    for (let i = 0; i < taskCount; i++) {
      const task = await prisma.task.create({
        data: {
          title: taskTitles[i % taskTitles.length],
          description: `${project.name} のタスク #${i + 1} の詳細な説明`,
          status: statuses[i % statuses.length],
          priority: priorities[i % priorities.length],
          projectId: project.id,
          assigneeId: users[i % users.length].id,
          creatorId: users[0].id,
          order: i,
          dueDate: new Date(Date.now() + (i + 1) * 86400000 * 7),
        },
      })

      if (i % 3 === 0) {
        await prisma.comment.create({
          data: {
            content: `このタスクは順調に進んでいます。実装のレビューが必要です。`,
            taskId: task.id,
            authorId: users[(i + 1) % users.length].id,
          },
        })
      }
    }
  }

  const totalTasks = await prisma.task.count()
  const totalComments = await prisma.comment.count()

  console.log(`✅ シード完了:
  - 1 組織 (${org.name})
  - ${users.length} ユーザー
  - ${projects.length} プロジェクト
  - ${totalTasks} タスク
  - ${totalComments} コメント`)
}

main()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })
💻 出力:

TEXT 📖 参照専用
🌱 データベースをシード中...
✅ シード完了:
  - 1 組織 (Acme Corp)
  - 5 ユーザー
  - 5 プロジェクト
  - 50 タスク
  - 16 コメント
BASH
# package.json に seed スクリプトを追加
# "prisma": { "seed": "tsx src/prisma/seed.ts" }

# シードの実行
npx prisma db seed
TEXT 📖 参照専用
🌱 データベースをシード中...
✅ シード完了:
  - 1 組織 (Acme Corp)
  - 5 ユーザー
  - 5 プロジェクト
  - 50 タスク
  - 16 コメント


8. 完全な例: プロジェクト初期化から認証までの全プロセス

BASH
# ============================================
# TaskFlow 完全初期化プロセス
# ============================================

# 1. プロジェクトの作成
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd taskflow

# 2. 依存関係のインストール
npm install next-auth@beta @prisma/client @auth/prisma-adapter bcryptjs
npm install prisma tsx --save-dev
npm install @types/bcryptjs --save-dev

# 3. shadcn/ui の初期化
npx shadcn@latest init -d
npx shadcn@latest add button card input label select dialog dropdown-menu avatar badge separator

# 4. Prisma の初期化
npx prisma init --datasource-provider postgresql
# schema.prisma の内容をコピー
npx prisma migrate dev --name init
npx prisma generate

# 5. 環境変数の設定 (.env)
cat > .env << EOF
DATABASE_URL="postgresql://postgres:password@localhost:5432/taskflow"
AUTH_SECRET="my-super-secret-key-change-in-production"
AUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
EOF

# 6. シードの実行
npx prisma db seed

# 7. 開発サーバーの起動
npm run dev

ブラウザで http://localhost:3000 にアクセスした場合の期待される出力:

TEXT 📖 参照専用
→ 自動的に /auth/signin にリダイレクト(未ログイン)
→ ログインフォームを表示(メール + パスワード + Google)
→ alice@acme.com / password123 でログイン
→ /dashboard に遷移
→ サイドバー表示: Acme Corp · 5 プロジェクト · 50 タスク

❓ よくある質問

Q なぜ Clerk ではなく Auth.js を使用するのですか?
A Auth.js(NextAuth v5)はオープンソースのソリューションで、80 以上のプロバイダーをサポートし、ベンダーロックインを回避できます。Clerk は迅速なプロトタイピングには適していますが、有料化されるとかなり高額になります。Auth.js を使用することで、TaskFlow は認証ロジックを完全に制御でき、Prisma とシームレスに統合できます。
Q Prisma スキーマを変更した後、データベースを同期するにはどうすればよいですか?
A npx prisma migrate dev --name <説明> を実行して新しいマイグレーションを作成します。Prisma が自動的に SQL を生成して実行します。開発環境では、npx prisma db push を使用して直接同期することもできます(マイグレーションファイルは生成されません)。
Q 招待コードの TKFLW- というプレフィックスの目的は何ですか?
A プレフィックスは、このコードが TaskFlow の招待コードであることを識別し、他のシステムとの混同を避けるためのものです。完全な招待コードの例:TKFLW-ACMECORP。プレフィックスと組織スラッグの組み合わせにより、招待コードは高い可読性と一意性を持ちます。
Q ミドルウェアの matcher 設定がすべてのパスにマッチするのはなぜですか?
A matcher 設定 /((?!_next/static|_next/image|favicon.ico).*) はすべての非静的リソースパスにマッチし、ミドルウェアがすべてのページルートと API ルートで認証チェックを実行できるようにします。Next.js は、matcher で過度にセグメント化するのではなく、ミドルウェア内でパスチェックを実行することを推奨しています。
Q シーディングにおける tsx とは何ですか?
A tsx は TypeScript エグゼキュータ(npm i tsx -D)で、コンパイルなしで .ts ファイルを直接実行できます。Prisma は公式に tsx をシードランナーとして推奨しています。ts-node も使用できますが、tsx の方が高速で互換性も優れています。
Q User モデルで passwordHash がオプショナルになっているのはなぜですか?
A Google OAuth ログインをサポートしているため、OAuth ユーザーはパスワードを必要としません。Credentials でログインするユーザーの場合、passwordHash に値が設定されます。OAuth ユーザーの場合、passwordHash は null になります。これはスキーマ設計の柔軟性を示しています。

📖 まとめ

📝 練習問題

  1. 基本問題 (⭐): このレッスンの手順に従って TaskFlow プロジェクトを初期化し、npm run dev を正常に実行して、ブラウザでログインページを表示します。結果のスクリーンショットを撮って保存します。

  2. 応用問題 (⭐⭐): Prisma スキーマに Label モデル(タグシステム)を追加し、Task と多対多の関係を確立し(中間テーブル TaskLabel 経由)、マイグレーションを実行して型を生成します。タグの CRUD API ルートを実装します。

  3. 発展問題 (⭐⭐⭐): Auth.js 設定を拡張して GitHub OAuth プロバイダーを追加します。middleware.ts で説明されているロールチェックロジックを実装します — OWNERADMIN ロールを持つユーザーのみが /dashboard/admin パスにアクセスできます。ロールガードロジックを検証するユニットテストを作成します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%