4.6 完整项目实战


4.6 完整项目实战

本节摘要:把全书知识组装成一个真实项目——一个带用户系统与文章功能的"极简内容平台":注册登录、发布文章、Server Actions 写数据、Prisma 存数据、SEO 配置、测试与部署。本节带你走完"规划 → 架构 → 数据 → 功能 → 部署"全流程。

先说结论

完成本节项目后,你应当能够:

  1. 独立规划一个 Next.js 项目的模块划分
  2. 整合 Prisma、NextAuth、Server Actions、Metadata
  3. 用服务器组件 + 客户端组件组织页面
  4. 编写覆盖核心流程的测试
  5. 部署到 Vercel 并配置环境

项目规划:极简内容平台

需求:一个博客内容平台——用户注册/登录、发布/编辑/删除文章、文章列表与详情、SEO 优化、Server Actions 数据变更。

一、项目规划:极简内容平台

项目搭建

npx create-next-app@latest content-platform --typescript --tailwind --eslint --app cd content-platform npm install @prisma/client next-auth bcryptjs npm install -D prisma npx prisma init

数据模型

// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" // 演示;生产换 PostgreSQL url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String passwordHash String role String @default("user") posts Post[] createdAt DateTime @default(now()) } model Post { id String @id @default(cuid()) title String content String published Boolean @default(true) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
npx prisma migrate dev --name init

认证

// lib/auth.ts import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { prisma } from "@/lib/db"; import { compare } from "bcryptjs"; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Credentials({ credentials: { email: {}, password: {} }, async authorize(credentials) { const user = await prisma.user.findUnique({ where: { email: credentials.email as string }, }); if (user && await compare(credentials.password as string, user.passwordHash)) { return { id: user.id, email: user.email, name: user.name, role: user.role }; } return null; }, }), ], session: { strategy: "jwt" }, callbacks: { jwt({ token, user }) { if (user) token.role = user.role; return token; }, session({ session, token }) { session.user.role = token.role; return session; }, }, });
// app/api/auth/[...nextauth]/route.ts import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers;
// middleware.ts —— 登录保护 import { auth } from "@/lib/auth"; export default auth((req) => { if (!req.auth && req.nextUrl.pathname.startsWith("/dashboard")) { return Response.redirect(new URL("/login", req.nextUrl)); } }); export const config = { matcher: ["/dashboard/:path*"] };

Server Actions

// lib/actions/post-actions.ts "use server"; import { prisma } from "@/lib/db"; import { auth } from "@/lib/auth"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; export async function createPost(formData: FormData) { const session = await auth(); if (!session?.user?.id) return { error: "请先登录" }; const title = (formData.get("title") ?? "").toString().trim(); const content = (formData.get("content") ?? "").toString().trim(); if (!title || !content) return { error: "标题与内容不能为空" }; await prisma.post.create({ data: { title, content, authorId: session.user.id }, }); revalidatePath("/"); redirect("/"); } export async function deletePost(id: string) { const session = await auth(); if (!session?.user?.id) return { error: "请先登录" }; const post = await prisma.post.findUnique({ where: { id } }); if (!post || (post.authorId !== session.user.id && session.user.role !== "admin")) { return { error: "无权删除" }; } await prisma.post.delete({ where: { id } }); revalidatePath("/"); return { success: true }; }

页面与 SEO

// app/page.tsx —— 首页(服务器组件 + 列表) import Link from "next/link"; import { prisma } from "@/lib/db"; export default async function Home() { const posts = await prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, take: 20, include: { author: { select: { name: true } } }, }); return ( <main className="mx-auto max-w-3xl p-8"> <h1 className="text-3xl font-bold">内容平台</h1> <ul className="mt-6 space-y-4"> {posts.map((p) => ( <li key={p.id} className="rounded-lg border p-4"> <Link href={`/posts/${p.id}`} className="text-xl font-semibold hover:underline"> {p.title} </Link> <p className="text-sm text-gray-500 mt-1"> {p.author.name} · {p.createdAt.toLocaleDateString()} </p> </li> ))} </ul> </main> ); }
// app/posts/[id]/page.tsx —— 详情 + SEO import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { prisma } from "@/lib/db"; export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }): Promise<Metadata> { const { id } = await params; const post = await prisma.post.findUnique({ where: { id } }); if (!post) return { title: "文章不存在" }; return { title: post.title, description: post.content.slice(0, 100) }; } export default async function PostPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const post = await prisma.post.findUnique({ where: { id }, include: { author: { select: { name: true } } }, }); if (!post) notFound(); return ( <article className="mx-auto max-w-3xl p-8"> <h1 className="text-3xl font-bold">{post.title}</h1> <p className="text-sm text-gray-500 mt-2"> {post.author.name} · {post.createdAt.toLocaleDateString()} </p> <div className="mt-6 whitespace-pre-wrap">{post.content}</div> </article> ); }
// app/posts/new/page.tsx —— 发布页(服务器组件 + Server Action) import { createPost } from "@/lib/actions/post-actions"; export default function NewPostPage() { return ( <form action={createPost} className="mx-auto max-w-3xl p-8 space-y-4"> <h1 className="text-2xl font-bold">发布文章</h1> <input name="title" placeholder="标题" required className="w-full rounded border p-2" /> <textarea name="content" placeholder="内容" required rows={10} className="w-full rounded border p-2" /> <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white"> 发布 </button> </form> ); }

测试与部署

// lib/actions/post-actions.test.ts —— 单元测试 import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { post: { create: vi.fn(), findUnique: vi.fn(), delete: vi.fn() } } })); vi.mock("@/lib/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); import { createPost } from "./post-actions"; import { auth } from "@/lib/auth"; describe("createPost", () => { beforeEach(() => vi.clearAllMocks()); it("未登录返回错误", async () => { (auth as any).mockResolvedValue(null); const form = new FormData(); const res = await createPost(form); expect(res).toEqual({ error: "请先登录" }); }); it("空内容返回错误", async () => { (auth as any).mockResolvedValue({ user: { id: "1" } }); const form = new FormData(); form.set("title", ""); const res = await createPost(form); expect(res.error).toBeDefined(); }); });
# 部署(Vercel) # 1. git 推送 # 2. vercel.com 导入,配置 DATABASE_URL、AUTH_SECRET # 3. 生产数据库(Vercel Postgres / Neon) # 4. 部署完成

实战复盘

环节 用到的知识
架构 4.2 分层与目录组织
数据 4.1 Prisma + 2.1 数据获取
认证 3.2 NextAuth + 中间件
数据变更 2.8 Server Actions + 3.2 内鉴权
SEO 2.6 Metadata API
测试 3.5 Vitest + mock
部署 3.6 Vercel + 环境变量
样式 2.4 Tailwind

进一步演进:加编辑功能(updatePost)、分页、评论区(Post 关联 Comment)、AI 摘要(Vercel AI SDK)、暗色主题、i18n——每一步都能用书里的知识完成。

要点速记

  • 完整闭环:规划 → 架构 → 数据 → 功能 → 测试 → 部署。
  • 技术整合:Prisma + NextAuth + Server Actions + Metadata 协同。
  • 安全默认:Action 内鉴权、角色校验、密码哈希。
  • 测试保障:mock 数据库与认证,覆盖核心 Action。
  • 部署配置:Vercel + 环境变量 + 生产数据库。
  • 演进方向:CRUD 完善、评论、AI 集成、i18n。
  • 毕业标准:这个项目跑通部署后,你就具备了独立交付 Next.js 全栈应用的能力。

深入理解:项目实战的复盘与延伸

项目的完整数据流

复盘:每步的"为什么"

环节 为什么这么做
服务器组件取数 SEO + 少发客户端 JS
Prisma 类型安全 + 迁移管理
NextAuth + 中间件 认证统一 + 入口拦截
Server Action 内鉴权 Action 是网络接口,必须自证
Metadata API 每篇文章独立 SEO
Vitest mock 测试不依赖真实数据库
Vercel 部署 环境隔离 + 自动回滚

延伸方向(用已有知识完成)

  1. 编辑功能:updatePost Action + 编辑表单;
  2. 分页:首页按页码查询,URL 带 page 参数;
  3. 评论:Post 关联 Comment 模型 + 评论表单(Server Action);
  4. AI 摘要:Vercel AI SDK 生成文章摘要(流式展示);
  5. 暗色主题:CSS 变量 + class 切换(2.4);
  6. 多语言:app/[lang]/ 改造(3.3);
  7. 性能:Suspense 拆首屏 + 图片优化(3.4)。

毕业标准:把项目部署到线上、写一个 README、请一个朋友试用并反馈——能交付被使用的应用,才是真正的毕业。这本书到这里结束,但你的 Next.js 之旅刚刚开始。

常见问题速答

问:项目报错怎么排查?
按类型定位:路由 404 查目录结构与文件名;Action 报错看服务器日志;数据问题查 Prisma 查询与模型;部署问题看部署平台日志。每个错误都是一次学习机会

问:这个项目可以直接当模板用吗?
可以。作为起点:替换数据模型(Post → 你的业务实体)、扩展认证(加角色)、加你的功能模块。模板的价值是"已验证的结构"——专注业务,不重复造轮子。

问:如何加"编辑文章"功能?
照抄 createPost 模式:updatePost Action(鉴权 + 校验 + 更新)+ 编辑表单页(预填数据)+ revalidatePath 刷新。Server Actions 让增删改查变成同一套模式

问:下一步学什么?
推荐顺序:深入 Prisma(关系、聚合、索引)→ Redis 缓存 → 真实数据库(Postgres)部署 → AI 集成(Vercel AI SDK)→ 系统设计基础。项目驱动学习:每加一个功能,学一块知识。

一句话总结

完整项目的价值是"把碎片拼成体系"——路由、数据、认证、Actions、SEO、测试、部署,在真实项目中各司其职。做完并上线它,你就完成了从"会写组件"到"能交付产品"的转变。项目永远可以更好:加评论、分页、AI 摘要、暗色主题——每一步都是新的学习。

动手练习建议

把 4.6 项目扩展三个功能,每个都复用书里已有模式:

  1. 编辑文章:updatePost Action(照抄 createPost)+ 编辑表单页(预填数据)+ revalidatePath;
  2. 分页:首页按页码查询,URL 带 page 参数,列表底部上一页/下一页链接;
  3. 评论:新增 Comment 模型(关联 Post),文章详情页显示评论列表 + 提交评论的表单(Server Action)。

三个功能做完,你已经在"用 Next.js 做产品"而不是"学 Next.js"了——这就是毕业的形态。之后可以继续:AI 摘要、暗色主题、多语言,每一个都是独立的进阶练习。

章节自测

能说出项目里每个技术选型的理由吗:为什么用 Prisma?为什么默认服务器组件取数?为什么 Action 内要鉴权?为什么部署到 Vercel?如果每个都能答出"为什么",你就不是"照着做",而是"懂着做"——这是独立开发的起点。

记住:这个项目不是终点而是模板——它的结构、模式(服务器组件取数、Action 写回、鉴权三层)可以直接复制到你的真实业务中。带着换业务不换骨架的视角看待它,你就掌握了复用的本质。建议把项目源码整理成你自己的模板仓库,下一次开发直接复用骨架。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U