引言
Next.js已经成为现代Web开发的事实标准。无论是初创公司的MVP还是大型企业的生产级应用,Next.js都是构建全栈Web应用的首选框架。
这篇文章来自我最近完成的一个真实SaaS项目——一个任务管理应用。通过这个项目,我会带你从头构建一个完整的全栈应用,涵盖从项目初始化到生产环境部署的全流程。
这个项目采用的技术栈:
- Next.js 15(App Router)
- TypeScript
- Prisma ORM + PostgreSQL
- NextAuth v5(认证)
- Tailwind CSS
- Vercel(部署)
无论你是想学习全栈开发,还是想了解Next.js的最新特性,这个项目都能给你一些参考。

项目需求分析
核心功能
我们的任务是构建一个任务管理SaaS应用,核心功能包括:
用户模块:
- 用户注册与登录(邮箱+密码、第三方登录)
- 用户个人资料管理
- 团队创建与管理
- 成员邀请与角色权限
任务模块:
- 创建、编辑、删除任务
- 任务状态管理(待办、进行中、已完成)
- 任务优先级设置
- 截止日期管理
- 任务评论与讨论
项目管理:
- 创建多个项目
- 项目成员管理
- 项目概览与统计
技术选型理由
为什么用App Router而不是Pages Router?
Next.js 13引入的App Router是未来的方向,Server Components、嵌套布局、流式渲染等特性都在App Router中才能使用。2026年,新项目应该直接使用App Router。
为什么用Prisma而不是其他ORM?
Prisma的类型安全做得很好,Schema定义清晰,迁移方便,而且对Next.js生态支持很好。
为什么用NextAuth而不是其他认证方案?
NextAuth是专门为Next.js设计的认证方案,开箱即用,支持多种认证提供商,社区活跃。
项目初始化
创建项目
bash
# 使用create-next-app创建项目
npx create-next-app@latest taskflow \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
# 进入项目目录
cd taskflow
# 安装核心依赖
npm install prisma @prisma/client
npm install next-auth@beta @auth/prisma-adapter
npm install zod react-hook-form @hookform/resolvers
npm install lucide-react
npm install clsx tailwind-merge
# 开发依赖
npm install -D prisma
项目结构
plaintext
taskflow/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # 认证相关页面
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── (dashboard)/ # 仪表盘(需登录)
│ │ │ ├── layout.tsx
│ │ │ ├── projects/
│ │ │ └── settings/
│ │ ├── api/ # API路由
│ │ │ ├── auth/
│ │ │ └── projects/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components/ # React组件
│ │ ├── ui/ # 基础UI组件
│ │ └── features/ # 功能组件
│ ├── lib/ # 工具函数
│ │ ├── db.ts # 数据库客户端
│ │ ├── auth.ts # 认证配置
│ │ └── utils.ts # 通用工具
│ └── types/ # TypeScript类型
├── prisma/
│ └── schema.prisma # 数据库Schema
└── package.json
数据库设计
Prisma Schema定义
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户模型
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String? // 邮箱登录时加密存储
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联关系
accounts Account[]
sessions Session[]
ownedProjects Project[] @relation("ProjectOwner")
memberships Membership[]
tasks Task[] @relation("TaskAssignee")
@@map("users")
}
// OAuth账户
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])
@@map("accounts")
}
// 会话
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// 验证令牌
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// 项目
model Project {
id String @id @default(cuid())
name String
description String?
color String @default("#6366f1")
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
memberships Membership[]
tasks Task[]
@@map("projects")
}
// 项目成员
model Membership {
id String @id @default(cuid())
userId String
projectId String
role Role @default(MEMBER)
joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([userId, projectId])
@@map("memberships")
}
enum Role {
OWNER
ADMIN
MEMBER
}
// 任务
model Task {
id String @id @default(cuid())
title String
description String? @db.Text
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
dueDate DateTime?
projectId String
assigneeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
comments Comment[]
@@index([projectId])
@@index([assigneeId])
@@map("tasks")
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}
// 任务评论
model Comment {
id String @id @default(cuid())
content String @db.Text
taskId String
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
@@map("comments")
}
数据库迁移
bash
# 创建迁移
npx prisma migrate dev --name init
# 生成Prisma Client
npx prisma generate
# 查看数据库
npx prisma studio
认证系统实现
NextAuth v5配置
typescript
// src/lib/auth.ts
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import CredentialsProvider from 'next-auth/providers/credentials'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: 'jwt' },
pages: {
signIn: '/login',
error: '/login',
},
providers: [
// 邮箱密码登录
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error('请输入邮箱和密码')
}
const user = await db.user.findUnique({
where: { email: credentials.email as string }
})
if (!user || !user.password) {
throw new Error('用户不存在或未设置密码')
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!isValid) {
throw new Error('密码错误')
}
return user
}
}),
// GitHub OAuth
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Google OAuth
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string
}
return session
}
}
})
注册API
typescript
// src/app/api/auth/register/route.ts
import { NextResponse } from 'next/server'
import { hash } from 'bcryptjs'
import { z } from 'zod'
import { db } from '@/lib/db'
const registerSchema = z.object({
name: z.string().min(2, '姓名至少2个字符'),
email: z.string().email('请输入有效的邮箱'),
password: z.string().min(8, '密码至少8个字符')
})
export async function POST(req: Request) {
try {
const body = await req.json()
const { name, email, password } = registerSchema.parse(body)
// 检查用户是否已存在
const existingUser = await db.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: '该邮箱已被注册' },
{ status: 400 }
)
}
// 加密密码
const hashedPassword = await hash(password, 12)
// 创建用户
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword
},
select: {
id: true,
name: true,
email: true
}
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors[0].message },
{ status: 400 }
)
}
return NextResponse.json(
{ error: '注册失败,请稍后重试' },
{ status: 500 }
)
}
}
Server Components与数据获取
服务端组件的优势
App Router的核心优势是Server Components。默认情况下,app目录下的组件都是Server Components,它们在服务端执行,可以直接访问数据库,生成的HTML直接发送到客户端。
tsx
// src/app/(dashboard)/projects/page.tsx
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { redirect } from 'next/navigation'
import Link from 'next/link'
// 这个组件在服务端执行
export default async function ProjectsPage() {
// 直接访问数据库,不需要API
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const projects = await db.project.findMany({
where: {
OR: [
{ ownerId: session.user.id! },
{ memberships: { some: { userId: session.user.id! } } }
]
},
include: {
_count: {
select: { tasks: true, memberships: true }
}
},
orderBy: { updatedAt: 'desc' }
})
return (
<div className="container mx-auto py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">我的项目</h1>
<Link
href="/projects/new"
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700"
>
创建项目
</Link>
</div>
{projects.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-500 mb-4">还没有项目,创建一个开始吧</p>
<Link
href="/projects/new"
className="text-indigo-600 hover:text-indigo-700"
>
创建第一个项目 →
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
)
}
// 子组件(也是Server Component)
function ProjectCard({ project }: { project: any }) {
return (
<Link href={`/projects/${project.id}`}>
<div className="border rounded-lg p-6 hover:shadow-lg transition-shadow">
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-lg"
style={{ backgroundColor: project.color }}
/>
<h3 className="font-semibold text-lg">{project.name}</h3>
</div>
{project.description && (
<p className="text-gray-500 text-sm mb-4 line-clamp-2">
{project.description}
</p>
)}
<div className="flex gap-4 text-sm text-gray-500">
<span>{project._count.tasks} 个任务</span>
<span>{project._count.memberships} 位成员</span>
</div>
</div>
</Link>
)
}
Client Components的使用
当组件需要交互(点击、表单、状态)时,需要标记为"use client"。
tsx
// src/components/features/tasks/task-form.tsx
"use client"
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const taskSchema = z.object({
title: z.string().min(1, '请输入任务标题'),
description: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']),
dueDate: z.string().optional()
})
type TaskFormData = z.infer<typeof taskSchema>
export function TaskForm({
projectId,
onSuccess
}: {
projectId: string
onSuccess?: () => void
}) {
const [isSubmitting, setIsSubmitting] = useState(false)
const {
register,
handleSubmit,
reset,
formState: { errors }
} = useForm<TaskFormData>({
resolver: zodResolver(taskSchema)
})
const onSubmit = async (data: TaskFormData) => {
setIsSubmitting(true)
try {
const response = await fetch(`/api/projects/${projectId}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (response.ok) {
reset()
onSuccess?.()
}
} catch (error) {
console.error('创建任务失败:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">任务标题</label>
<input
{...register('title')}
className="w-full border rounded-lg px-3 py-2"
/>
{errors.title && (
<p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">优先级</label>
<select
{...register('priority')}
className="w-full border rounded-lg px-3 py-2"
>
<option value="LOW">低</option>
<option value="MEDIUM">中</option>
<option value="HIGH">高</option>
<option value="URGENT">紧急</option>
</select>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-indigo-600 text-white py-2 rounded-lg disabled:opacity-50"
>
{isSubmitting ? '创建中...' : '创建任务'}
</button>
</form>
)
}
API路由开发
RESTful API设计
typescript
// src/app/api/projects/[projectId]/tasks/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { z } from 'zod'
// 获取项目所有任务
export async function GET(
req: Request,
{ params }: { params: { projectId: string } }
) {
try {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
// 验证用户是否有权限访问该项目
const membership = await db.membership.findUnique({
where: {
userId_projectId: {
userId: session.user.id!,
projectId: params.projectId
}
}
})
if (!membership) {
return NextResponse.json({ error: '无权访问该项目' }, { status: 403 })
}
const tasks = await db.task.findMany({
where: { projectId: params.projectId },
include: {
assignee: {
select: { id: true, name: true, image: true }
},
_count: { select: { comments: true } }
},
orderBy: [
{ status: 'asc' },
{ priority: 'desc' },
{ createdAt: 'desc' }
]
})
return NextResponse.json(tasks)
} catch (error) {
return NextResponse.json({ error: '获取失败' }, { status: 500 })
}
}
// 创建任务
const createTaskSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('MEDIUM'),
assigneeId: z.string().optional()
})
export async function POST(
req: Request,
{ params }: { params: { projectId: string } }
) {
try {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
const membership = await db.membership.findUnique({
where: {
userId_projectId: {
userId: session.user.id!,
projectId: params.projectId
}
}
})
if (!membership) {
return NextResponse.json({ error: '无权访问该项目' }, { status: 403 })
}
const body = await req.json()
const data = createTaskSchema.parse(body)
const task = await db.task.create({
data: {
...data,
projectId: params.projectId,
assigneeId: data.assigneeId || session.user.id
},
include: {
assignee: {
select: { id: true, name: true, image: true }
}
}
})
return NextResponse.json(task, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
return NextResponse.json({ error: '创建失败' }, { status: 500 })
}
}
性能优化
图片优化
Next.js内置图片优化,使用next/image组件:
tsx
import Image from 'next/image'
export function UserAvatar({ src, name }: { src?: string, name: string }) {
return (
<div className="relative w-10 h-10 rounded-full overflow-hidden">
{src ? (
<Image
src={src}
alt={name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
{name.charAt(0).toUpperCase()}
</div>
)}
</div>
)
}
字体优化
tsx
// src/app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>{children}</body>
</html>
)
}
路由预加载
tsx
// Link组件会自动预加载可见链接的代码
import Link from 'next/link'
// 对于不可见的链接,可以手动预加载
import { useRouter } from 'next/navigation'
const router = useRouter()
// 预加载页面
router.prefetch('/projects/new')
部署与运维
Vercel部署
bash
# 安装Vercel CLI
npm i -g vercel
# 登录
vercel login
# 部署
vercel
# 生产环境部署
vercel --prod
环境变量配置
在Vercel后台配置以下环境变量:
DATABASE_URL:PostgreSQL数据库连接字符串AUTH_SECRET:NextAuth加密密钥GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET:GitHub OAuth凭证GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET:Google OAuth凭证
数据库选择
推荐使用Vercel官方推荐的数据库服务:
- Neon:Serverless PostgreSQL,免费额度充足
- Supabase:PostgreSQL + 额外功能
- PlanetScale:MySQL兼容,Serverless架构
项目亮点总结
通过这个项目,我们实现了:
- 完整的认证系统:邮箱登录 + OAuth第三方登录
- 基于角色的权限控制:Owner、Admin、Member三种角色
- 现代化的UI开发:React Server Components + Client Components混合
- 类型安全:TypeScript + Prisma的端到端类型安全
- 响应式设计:Tailwind CSS实现移动端适配
扩展功能建议
项目完成后,可以考虑添加以下功能:
- 实时协作:集成Pusher或Ably实现实时更新
- 邮件通知:集成Resend发送任务到期提醒
- 数据导出:导出项目数据为CSV/PDF
- API开放:为其他应用提供API接口
- 主题切换:支持暗色/亮色主题
结语
这个项目覆盖了Next.js全栈开发的核心技能点。通过实际动手构建项目,你不仅学会了如何使用这些技术,更重要的是理解了为什么要这样设计。
全栈开发的核心能力是系统思维——能够从整体视角考虑前端、后端、数据库、认证、安全等各个方面的配合。这种能力只有在实战项目中才能真正锻炼出来。
建议你在学习完这个项目后,尝试添加一些自己的功能创意,比如:
- 添加任务标签功能
- 实现项目甘特图
- 添加任务时间追踪
期待看到你的作品!
相关资源推荐:
延伸阅读:

发表回复