Next.js: 最終プロジェクト: 認証とセットアップ
最終更新:2026-08-26
完全な SaaS プロジェクトを構築することは建物を建てるようなものです — まず足場を組んで構造フレームワークを立て、次に電気・配管システム(データベース)を設計し、最後にドアロック(認証システム)を取り付けます。
1. 学習内容
create-next-app+ Tailwind + shadcn/ui を使用してエンタープライズレベルのプロジェクトを初期化する- Prisma スキーマ: 5 つの設計モデル(User / Organization / Project / Task / Comment)と列挙型
- Auth.js v5 統合: Credentials と Google OAuth を使用したデュアル認証ソリューション
- 組織招待コードシステムとメンバー管理
- ミドルウェアルート保護と集中セッション管理
- データベースシーディング: 100 件のサンプルレコードを生成する
2. あるフルスタックエンジニアの実話
(1) 課題:ゼロから SaaS プラットフォームを構築する混沌としたスタート
Alice は 50 人のスタートアップで働くフルスタックエンジニアです。会社は社内プロジェクトコラボレーションプラットフォーム「TaskFlow」を開発することを決定し、10,000 人のユーザーにサービスを提供し、各ユーザーが 200 以上のタスクを管理します。Alice は手動でプロジェクト構造をセットアップしようとしましたが — ルーティングが混乱し、データベースマイグレーションが頻繁に失敗し、提案した 3 つの認証ソリューションはすべて却下されました。チームは環境設定に 1 日 4 時間を無駄にし、プロジェクトは予定より 2 週間遅れていました。
(2) スキャフォールディング + 認証フレームワークを使用したソリューション
create-next-appによるワンクリック初期化 + Prisma ORM による宣言的モデリング + Auth.js のすぐに使える認証。
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 スキャフォールディングツールの選択
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 プロジェクトの初期化
データフローとコンポーネント構造の図。
# 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
✔ プロジェクトが作成されました: taskflow/
✔ shadcn/ui が正常に初期化されました
✔ コンポーネント button がインストールされました
✔ コンポーネント card がインストールされました
✔ コンポーネント input がインストールされました
✔ 合計 7 つのコンポーネントがインストールされました
✔ プロジェクトが作成されました: taskflow/
✔ shadcn/ui が正常に初期化されました
✔ コンポーネント button がインストールされました
✔ コンポーネント card がインストールされました
✔ コンポーネント input がインストールされました
✔ 合計 7 つのコンポーネントがインストールされました
(2) プロジェクトディレクトリ構造
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) データモデル関係図
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 スキーマ
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)
}
▶ サンプル: データベースマイグレーションの実行
# 1. マイグレーションの作成
npx prisma migrate dev --name init
# 2. Prisma クライアントの生成
npx prisma generate
# 3. データベースの表示
npx prisma studio
✔ 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 設定
// 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 ルート
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
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) 組織招待コードシステム
// 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
}
▶ サンプル: ログインと登録ページ
データベースレコードを更新します。
// 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>
)
}
SignInPage コンポーネントの UI をレンダリングします。
// 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) ミドルウェアワークフロー
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) ミドルウェア実装
// 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).*)",
],
}
▶ サンプル: ミドルウェアルート保護のテスト
# 保護ルートへのアクセス(未ログイン)
curl -v http://localhost:3000/dashboard 2>&1 | grep Location
# API ルートへのアクセス(未ログイン)
curl -v http://localhost:3000/api/projects 2>&1
< 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) シーディング戦略
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
▶ サンプル: 完全なシーディングスクリプト
シードフロー図: 組織の作成 → ユーザー → プロジェクト → タスク → コメント。
// 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()
})
🌱 データベースをシード中...
✅ シード完了:
- 1 組織 (Acme Corp)
- 5 ユーザー
- 5 プロジェクト
- 50 タスク
- 16 コメント
# package.json に seed スクリプトを追加
# "prisma": { "seed": "tsx src/prisma/seed.ts" }
# シードの実行
npx prisma db seed
🌱 データベースをシード中...
✅ シード完了:
- 1 組織 (Acme Corp)
- 5 ユーザー
- 5 プロジェクト
- 50 タスク
- 16 コメント
8. 完全な例: プロジェクト初期化から認証までの全プロセス
# ============================================
# 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 にアクセスした場合の期待される出力:
→ 自動的に /auth/signin にリダイレクト(未ログイン)
→ ログインフォームを表示(メール + パスワード + Google)
→ alice@acme.com / password123 でログイン
→ /dashboard に遷移
→ サイドバー表示: Acme Corp · 5 プロジェクト · 50 タスク
❓ よくある質問
npx prisma migrate dev --name <説明> を実行して新しいマイグレーションを作成します。Prisma が自動的に SQL を生成して実行します。開発環境では、npx prisma db push を使用して直接同期することもできます(マイグレーションファイルは生成されません)。TKFLW-ACMECORP。プレフィックスと組織スラッグの組み合わせにより、招待コードは高い可読性と一意性を持ちます。/((?!_next/static|_next/image|favicon.ico).*) はすべての非静的リソースパスにマッチし、ミドルウェアがすべてのページルートと API ルートで認証チェックを実行できるようにします。Next.js は、matcher で過度にセグメント化するのではなく、ミドルウェア内でパスチェックを実行することを推奨しています。npm i tsx -D)で、コンパイルなしで .ts ファイルを直接実行できます。Prisma は公式に tsx をシードランナーとして推奨しています。ts-node も使用できますが、tsx の方が高速で互換性も優れています。User モデルで passwordHash がオプショナルになっているのはなぜですか?Credentials でログインするユーザーの場合、passwordHash に値が設定されます。OAuth ユーザーの場合、passwordHash は null になります。これはスキーマ設計の柔軟性を示しています。📖 まとめ
create-next-appのフレームワーク選択: TypeScript + Tailwind + App Router + src ディレクトリをエンタープライズレベルのプロジェクトの出発点として使用します。- shadcn/ui は依存関係のない UI コンポーネントライブラリを提供し、パッケージサイズを増やすことなくオンデマンドでインストールできます。
- Prisma スキーマは宣言的構文を使用して User、Organization、Project、Task、Comment の 5 つのモデルと列挙型を定義します。
- Auth.js v5 は Credentials と Google OAuth によるデュアル認証をサポートします。JWT セッションポリシーではセッションのデータベース保存は不要です。
- ミドルウェアは
auth()ヘルパーと matcher 設定を通じてグローバルなルート保護を実装します。 - 組織招待コードシステム(
TKFLW-プレフィックス付き)により、チームメンバーが自己参加できるようになります。 - データベースシーディングは tsx ドライバーを使用し、100 件以上のリアルなデモレコードを生成できます。
📝 練習問題
-
基本問題 (⭐): このレッスンの手順に従って TaskFlow プロジェクトを初期化し、
npm run devを正常に実行して、ブラウザでログインページを表示します。結果のスクリーンショットを撮って保存します。 -
応用問題 (⭐⭐): Prisma スキーマに
Labelモデル(タグシステム)を追加し、Taskと多対多の関係を確立し(中間テーブルTaskLabel経由)、マイグレーションを実行して型を生成します。タグの CRUD API ルートを実装します。 -
発展問題 (⭐⭐⭐): Auth.js 設定を拡張して GitHub OAuth プロバイダーを追加します。
middleware.tsで説明されているロールチェックロジックを実装します —OWNERとADMINロールを持つユーザーのみが/dashboard/adminパスにアクセスできます。ロールガードロジックを検証するユニットテストを作成します。