Next.js: 综合项目:初始化与认证

最后更新:2026-08-26

搭建一个完整的 SaaS 项目就像盖一栋楼——先用脚手架搭好结构框架,再设计水电线路(数据库),最后装上大门锁具(认证系统)。

1. 你将学到


2. 一个全栈工程师的真实故事

(1) 痛点:从零搭建 SaaS 平台的混乱开局

Alice 是一家 50 人创业公司的全栈工程师。公司决定开发一个内部项目协同管理平台 "TaskFlow",服务 10,000 名用户,每人管理 200+ 任务。Alice 试过手动搭建项目结构——但路由保护混乱、数据库迁移频繁出错、认证方案选了 3 个都被否决。团队每天浪费 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

三行命令解决了 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 Yes 类型安全,生产必备
ESLint Yes 代码规范
Tailwind CSS Yes 与 shadcn/ui 配合
src/ 目录 Yes app/ 分开,结构清晰
App Router Yes Next.js 16 默认
import alias @/* 简洁导入路径

▶ 示例:初始化 TaskFlow 项目

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 📖 仅展示
✔ Project created at: taskflow/
✔ shadcn/ui initialized successfully
✔ Component button installed
✔ Component card installed
✔ Component input installed
✔ 7 components installed in total

(2) 项目目录结构

TEXT 📖 仅展示
taskflow/
├── src/
│   ├── app/                    # App Router 页面
│   │   ├── (auth)/            # 认证相关路由组
│   │   ├── (dashboard)/       # 仪表盘路由组
│   │   ├── api/               # API Routes
│   │   ├── layout.tsx         # 根布局
│   │   └── page.tsx           # 首页
│   ├── components/            # 共享组件
│   │   ├── ui/               # shadcn/ui 组件
│   │   └── forms/            # 表单组件
│   ├── lib/                  # 工具函数
│   │   ├── auth.ts           # Auth.js 配置
│   │   ├── db.ts             # Prisma 客户端
│   │   └── utils.ts          # 通用工具
│   ├── prisma/               # Prisma Schema + 迁移
│   │   ├── schema.prisma
│   │   └── seed.ts
│   └── middleware.ts         # 路由保护中间件
├── public/                   # 静态资源
├── next.config.ts            # Next.js 配置
├── tailwind.config.ts        # Tailwind 配置
└── package.json

4. Prisma Schema 五模型设计

(1) 数据模型关系图

100%
graph TB
    U[User] -->|belongs to| O[Organization]
    O -->|has many| P[Project]
    P -->|has many| T[Task]
    T -->|has many| C[Comment]
    U -->|assigned to| T
    U -->|created by| C
    U -->|created by| 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 Schema

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 Client
npx prisma generate

# 3. 查看数据库
npx prisma studio
TEXT 📖 仅展示
✔ Generated Prisma Client (v6.x) to .\node_modules\@prisma\client
✔ Your database is now in sync with your Prisma schema
✔ Prisma Studio opened at http://localhost:5555

5. Auth.js 认证集成

(1) 认证方案对比

方案 优势 劣势 适用场景
Auth.js v5 多 Provider、内置 Session、类型安全 学习曲线中等 需要灵活认证的项目
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: "Email", type: "email" },
        password: { label: "Password", 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 Routes

TYPESCRIPT
// 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: "Email already registered" },
        { 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}'s Organization`,
          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: "User created successfully" },
      { status: 201 }
    )
  } catch (error) {
    return NextResponse.json(
      { error: "Something went wrong" },
      { 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 format
  const fullCode = `TKFLW-${code.toUpperCase()}`
  const org = await prisma.organization.findUnique({
    where: { inviteCode: fullCode },
  })
  return org
}

▶ 示例:登录与注册页面

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="Sign in to TaskFlow"
        description="Enter your credentials to continue"
      >
        <SignInForm />
      </AuthCard>
    </div>
  )
}
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("Invalid email or password")
      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">Email</Label>
            <Input
              id="email"
              name="email"
              type="email"
              placeholder="alice@example.com"
              required
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input
              id="password"
              name="password"
              type="password"
              placeholder="Enter your password"
              required
            />
          </div>
          {error && (
            <p className="text-sm text-red-500">{error}</p>
          )}
          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? "Signing in..." : "Sign in"}
          </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">
                Or continue with
              </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>
            Sign in with Google
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}

6. Middleware 路由保护

(1) 中间件工作流程

100%
graph LR
    A[Request] --> B{Middleware}
    B -->|Public path| C[Allow]
    B -->|Protected path| D{Has session?}
    D -->|Yes| C
    D -->|No| E[Redirect /auth/signin]
    B -->|API route| F{Valid token?}
    F -->|Yes| G[Forward]
    F -->|No| H[401 Unauthorized]

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

(2) Middleware 实现

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).*)",
  ],
}

▶ 示例:Middleware 路由保护测试

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. 数据库 Seeding

(1) Seeding 策略

100%
graph TB
    A[seed.ts] --> B[Create Organization]
    B --> C[Create Users x5]
    C --> D[Create Projects x5]
    D --> E[Create Tasks x50]
    E --> F[Create Comments x40]
    F --> G[Create Attachments x10]

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

▶ 示例:完整 Seeding 脚本

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("🌱 Seeding database...")

  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: "Website Redesign", description: "Redesign the company website with modern UI", color: "#3b82f6" },
    { name: "Mobile App v2", description: "Version 2 of the mobile application", color: "#10b981" },
    { name: "API Integration", description: "Third-party API integrations", color: "#f59e0b" },
    { name: "Data Migration", description: "Migrate data from legacy system", color: "#ef4444" },
    { name: "Security Audit", description: "Q3 security audit and compliance", color: "#8b5cf6" },
  ]

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

  const taskTitles = [
    "Set up CI/CD pipeline", "Design system components", "Write unit tests",
    "Implement user authentication", "Create database schema", "API documentation",
    "Performance optimization", "Accessibility audit", "Mobile responsive layout",
    "Error handling middleware", "Data validation", "Search functionality",
    "Notification system", "File upload feature", "User dashboard",
    "Admin panel", "Reporting module", "Email templates",
    "WebSocket integration", "Cache layer", "Rate limiting",
    "Logging system", "Backup strategy", "Monitoring setup",
    "Load testing",
  ]

  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 === "Website Redesign" ? 20 : 15

    for (let i = 0; i < taskCount; i++) {
      const task = await prisma.task.create({
        data: {
          title: taskTitles[i % taskTitles.length],
          description: `Detailed description for task #${i + 1} in ${project.name}`,
          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: `This task is progressing well. Need to review the implementation.`,
            taskId: task.id,
            authorId: users[(i + 1) % users.length].id,
          },
        })
      }
    }
  }

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

  console.log(`✅ Seeding complete:
  - 1 Organization (${org.name})
  - ${users.length} Users
  - ${projects.length} Projects
  - ${totalTasks} Tasks
  - ${totalComments} Comments`)
}

main()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })
BASH
# package.json 添加 seed 脚本
# "prisma": { "seed": "tsx src/prisma/seed.ts" }

# 运行 seeding
npx prisma db seed
TEXT 📖 仅展示
🌱 Seeding database...
✅ Seeding complete:
  - 1 Organization (Acme Corp)
  - 5 Users
  - 5 Projects
  - 50 Tasks
  - 16 Comments

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. 运行 Seeding
npx prisma db seed

# 7. 启动开发服务器
npm run dev

预期输出(浏览器访问 http://localhost:3000):

TEXT 📖 仅展示
→ 自动跳转到 /auth/signin(未登录)
→ 显示登录表单(Email + Password + Google)
→ 使用 alice@acme.com / password123 登录
→ 跳转到 /dashboard
→ 侧边栏显示:Acme Corp · 5 Projects · 50 Tasks

❓ 常见问题

Q 为什么用 Auth.js 而不是 Clerk?
A Auth.js(NextAuth v5)是开源方案,支持 80+ Provider,无厂商锁定。Clerk 适合快速原型但付费后成本较高。TaskFlow 用 Auth.js 可以完全自控认证逻辑,且与 Prisma 无缝集成。
Q Prisma Schema 修改后如何同步数据库?
A 运行 npx prisma migrate dev --name <描述> 创建新迁移,Prisma 会自动生成 SQL 并执行。开发环境可以用 npx prisma db push 直接同步(不生成迁移文件)。
Q 邀请码 TKFLW- 前缀有什么用?
A 前缀用于标识这是 TaskFlow 的邀请码,避免与其他系统混淆。完整邀请码示例:TKFLW-ACMECORP。前缀 + 组织 slug 的组合让邀请码可读性高且唯一。
Q Middleware 中的 matcher 配置为什么匹配所有路径?
A matcher 配置 /((?!_next/static|_next/image|favicon.ico).*) 匹配所有非静态资源路径,让中间件对所有页面路由和 API 路由进行认证检查。Next.js 推荐在 middleware 内部做路径判断而非在 matcher 中过度细分。
Q Seeding 中的 tsx 是什么?
A tsx 是 TypeScript 执行器(npm i tsx -D),可以直接运行 .ts 文件而无需编译。Prisma 官方推荐 tsx 作为 seed runner。也可以用 ts-node,但 tsx 速度更快且兼容性更好。
Q 为什么 User 模型中的 passwordHash 是可选的?
A 因为支持 Google OAuth 登录,OAuth 用户不需要密码。Credentials 登录用户的 passwordHash 有值,OAuth 用户的 passwordHash 为 null。这体现了 Schema 设计的灵活性。
Q 多个 Provider 的 session 如何共享?
A Auth.js 的 JWT 策略会自动处理。无论用户通过 Credentials 还是 Google 登录,生成的 JWT token 包含相同的 userId。Session 回调中通过 userId 查询数据库获取完整用户信息。

📖 小节

📝 作业

  1. 基础题(⭐):按照本课步骤完成 TaskFlow 项目初始化,成功运行 npm run dev 并在浏览器中看到登录页面。截图保存运行结果。

  2. 进阶题(⭐⭐):在 Prisma Schema 中添加一个 Label 模型(标签系统),与 Task 建立多对多关系(通过中间表 TaskLabel),然后运行迁移并生成类型。实现标签的 CRUD API Route。

  3. 挑战题(⭐⭐⭐):扩展 Auth.js 配置,添加 GitHub OAuth Provider;实现一个 middleware.ts 中的角色检查逻辑——只有 OWNERADMIN 角色的用户可以访问 /dashboard/admin 路径;编写单元测试验证角色守卫逻辑。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏