教程雨

OKX新手入门教程导航,收录OKX注册、充值、买币、提现等基础操作教程

Next.js全栈开发封面图,展示现代化SaaS应用构建与App Router实战

Next.js全栈项目实战:从零构建现代化SaaS应用完整教程

引言

Next.js已经成为现代Web开发的事实标准。无论是初创公司的MVP还是大型企业的生产级应用,Next.js都是构建全栈Web应用的首选框架。

这篇文章来自我最近完成的一个真实SaaS项目——一个任务管理应用。通过这个项目,我会带你从头构建一个完整的全栈应用,涵盖从项目初始化到生产环境部署的全流程。

这个项目采用的技术栈:

  • Next.js 15(App Router)
  • TypeScript
  • Prisma ORM + PostgreSQL
  • NextAuth v5(认证)
  • Tailwind CSS
  • Vercel(部署)

无论你是想学习全栈开发,还是想了解Next.js的最新特性,这个项目都能给你一些参考。

Next.js技术栈架构图,呈现App Router、Prisma ORM与NextAuth认证体系

项目需求分析

核心功能

我们的任务是构建一个任务管理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架构

项目亮点总结

通过这个项目,我们实现了:

  1. 完整的认证系统:邮箱登录 + OAuth第三方登录
  2. 基于角色的权限控制:Owner、Admin、Member三种角色
  3. 现代化的UI开发:React Server Components + Client Components混合
  4. 类型安全:TypeScript + Prisma的端到端类型安全
  5. 响应式设计:Tailwind CSS实现移动端适配

扩展功能建议

项目完成后,可以考虑添加以下功能:

  • 实时协作:集成Pusher或Ably实现实时更新
  • 邮件通知:集成Resend发送任务到期提醒
  • 数据导出:导出项目数据为CSV/PDF
  • API开放:为其他应用提供API接口
  • 主题切换:支持暗色/亮色主题

结语

这个项目覆盖了Next.js全栈开发的核心技能点。通过实际动手构建项目,你不仅学会了如何使用这些技术,更重要的是理解了为什么要这样设计。

全栈开发的核心能力是系统思维——能够从整体视角考虑前端、后端、数据库、认证、安全等各个方面的配合。这种能力只有在实战项目中才能真正锻炼出来。

建议你在学习完这个项目后,尝试添加一些自己的功能创意,比如:

  • 添加任务标签功能
  • 实现项目甘特图
  • 添加任务时间追踪

期待看到你的作品!

相关资源推荐

延伸阅读

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注