大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
综合练习
这一课你将能做到
- 从零搭建一个完整的 Next.js 项目(以「个人博客」为例),覆盖 App Router 全部核心能力
- 组合使用 Server Component、Client Component、Server Actions、Route Handlers
- 完成数据库连接、样式方案选择、SEO 配置、性能优化、部署上线
- 把前面 11 课的知识点串联成一套可交付的生产级工程实践
为什么要学
单独学每个知识点时,你看到的是碎片:路由是路由,数据是数据,样式是样式。但真实项目里,这些知识点是交织的——一个博客详情页同时涉及动态路由 blog/[slug]、generateMetadata 做 SEO、page.tsx 里 await prisma.post.findUnique() 直读数据库、loading.tsx 做骨架屏、error.tsx 做降级、next/image 做图片优化、revalidateTag 做缓存刷新。综合练习就是把这些碎片拼成一幅完整的图。
人话:前面 11 课是学「怎么打地基、怎么砌墙、怎么装窗户」,这一课是「盖一栋完整的房子」。你只有亲手盖一次,才知道地基和墙是怎么接在一起的。
先建立图像
博客项目结构:
my-blog/
├── app/
│ ├── layout.tsx ← 根布局,全局导航 + next/font
│ ├── page.tsx ← 首页,文章列表 + 搜索
│ ├── loading.tsx ← 首页骨架屏
│ ├── error.tsx ← 全局错误边界
│ ├── not-found.tsx ← 全局 404
│ ├── blog/
│ │ ├── page.tsx ← /blog 文章列表
│ │ ├── [slug]/
│ │ │ ├── page.tsx ← 文章详情(Server Component)
│ │ │ ├── loading.tsx ← 文章详情骨架屏
│ │ │ └── not-found.tsx ← 文章不存在 404
│ │ └── layout.tsx ← 博客区布局(左侧目录)
│ ├── dashboard/
│ │ ├── layout.tsx ← 管理台布局
│ │ ├── page.tsx ← 仪表盘
│ │ └── posts/
│ │ ├── page.tsx ← 文章管理列表
│ │ └── new/page.tsx ← 写文章(Server Action)
│ ├── api/
│ │ └── posts/route.ts ← REST API(备用)
│ └── globals.css ← 全局样式
├── components/
│ ├── ui/ ← 通用组件(Button、Card)
│ └── blog/ ← 博客专用组件
├── lib/
│ ├── db.ts ← Prisma 客户端单例
│ └── actions.ts ← Server Actions
├── prisma/
│ └── schema.prisma ← 数据库模型
├── public/
│ └── uploads/ ← 上传图片
├── Dockerfile ← 容器化部署
├── docker-compose.yml ← 本地开发数据库
├── .github/workflows/deploy.yml ← CI/CD
└── next.config.ts
跟着做
步骤 1:项目初始化
npx create-next-app@latest my-blog --typescript --tailwind --eslint --app --src-dir=false --no-turbopack
cd my-blog
npm install prisma @prisma/client
npx prisma init
prisma/schema.prisma:
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
slug String @unique
title String
excerpt String
content String
cover String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma db push
lib/db.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
步骤 2:全局布局与样式
app/layout.tsx:
import type { Metadata } from 'next';
import { Inter, Noto_Sans_SC } from 'next/font/google';
import Link from 'next/link';
import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const notoSansSC = Noto_Sans_SC({ subsets: ['latin'], weight: ['400', '700'], variable: '--font-noto' });
export const metadata: Metadata = {
title: { default: '我的博客', template: '%s | 我的博客' },
description: '一个用 Next.js 构建的个人博客',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className={`${inter.variable} ${notoSansSC.variable}`}>
<body className="font-sans antialiased">
<nav className="border-b bg-white">
<div className="mx-auto flex max-w-4xl items-center justify-between px-4 py-4">
<Link href="/" className="text-xl font-bold">我的博客</Link>
<div className="flex gap-6">
<Link href="/" className="text-gray-600 hover:text-gray-900">首页</Link>
<Link href="/blog" className="text-gray-600 hover:text-gray-900">文章</Link>
<Link href="/dashboard" className="text-gray-600 hover:text-gray-900">管理</Link>
</div>
</div>
</nav>
<main className="mx-auto max-w-4xl px-4 py-8">{children}</main>
<footer className="border-t bg-gray-50">
<div className="mx-auto max-w-4xl px-4 py-6 text-center text-sm text-gray-500">
© 2024 我的博客. Built with Next.js.
</div>
</footer>
</body>
</html>
);
}
tailwind.config.ts:
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-noto)', 'var(--font-inter)', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;
步骤 3:文章列表页(SSG + ISR)
app/blog/page.tsx:
import Link from 'next/link';
import { prisma } from '@/lib/db';
export const revalidate = 3600; // ISR: 1 小时刷新
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
});
return (
<div>
<h1 className="mb-6 text-3xl font-bold">全部文章</h1>
<div className="grid gap-6">
{posts.map(post => (
<article key={post.id} className="rounded-lg border p-6 hover:shadow-md transition-shadow">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-semibold text-blue-600 hover:underline">{post.title}</h2>
</Link>
<p className="mt-2 text-gray-600">{post.excerpt}</p>
<time className="mt-4 block text-sm text-gray-400">
{post.createdAt.toLocaleDateString('zh-CN')}
</time>
</article>
))}
</div>
</div>
);
}
app/blog/loading.tsx:
export default function BlogLoading() {
return (
<div>
<div className="mb-6 h-10 w-48 animate-pulse rounded bg-gray-200" />
<div className="grid gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="rounded-lg border p-6">
<div className="h-6 w-3/4 animate-pulse rounded bg-gray-200" />
<div className="mt-2 h-4 w-full animate-pulse rounded bg-gray-200" />
<div className="mt-4 h-4 w-24 animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
</div>
);
}
步骤 4:文章详情页(动态路由 + SEO)
app/blog/[slug]/page.tsx:
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/db';
import { revalidateTag } from 'next/cache';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) return { title: '文章不存在' };
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) notFound();
return (
<article className="prose max-w-none">
<h1 className="mb-4 text-3xl font-bold">{post.title}</h1>
<time className="text-gray-400">{post.createdAt.toLocaleDateString('zh-CN')}</time>
<div className="mt-8 whitespace-pre-wrap">{post.content}</div>
</article>
);
}
app/blog/[slug]/not-found.tsx:
import Link from 'next/link';
export default function PostNotFound() {
return (
<div className="text-center py-12">
<h1 className="text-4xl font-bold text-gray-300">404</h1>
<p className="mt-4 text-gray-600">这篇文章不存在或已被删除。</p>
<Link href="/blog" className="mt-6 inline-block text-blue-600 hover:underline">
返回文章列表
</Link>
</div>
);
}
步骤 5:管理台(Dashboard)
app/dashboard/layout.tsx:
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-8">
<aside className="w-48 shrink-0">
<nav className="space-y-2">
<a href="/dashboard" className="block rounded px-3 py-2 hover:bg-gray-100">仪表盘</a>
<a href="/dashboard/posts" className="block rounded px-3 py-2 hover:bg-gray-100">文章管理</a>
</nav>
</aside>
<div className="flex-1">{children}</div>
</div>
);
}
lib/actions.ts:
'use server';
import { prisma } from './db';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const slug = formData.get('slug') as string;
const excerpt = formData.get('excerpt') as string;
const content = formData.get('content') as string;
await prisma.post.create({
data: { title, slug, excerpt, content, published: true },
});
revalidatePath('/blog');
redirect('/dashboard/posts');
}
export async function deletePost(id: number) {
await prisma.post.delete({ where: { id } });
revalidatePath('/blog');
revalidatePath('/dashboard/posts');
}
app/dashboard/posts/new/page.tsx:
import { createPost } from '@/lib/actions';
export default function NewPostPage() {
return (
<div>
<h1 className="mb-6 text-2xl font-bold">写文章</h1>
<form action={createPost} className="space-y-4">
<div>
<label className="block text-sm font-medium">标题</label>
<input name="title" required className="mt-1 w-full rounded border px-3 py-2" />
</div>
<div>
<label className="block text-sm font-medium">Slug</label>
<input name="slug" required className="mt-1 w-full rounded border px-3 py-2" />
</div>
<div>
<label className="block text-sm font-medium">摘要</label>
<input name="excerpt" required className="mt-1 w-full rounded border px-3 py-2" />
</div>
<div>
<label className="block text-sm font-medium">内容</label>
<textarea name="content" rows={10} required className="mt-1 w-full rounded border px-3 py-2" />
</div>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
发布
</button>
</form>
</div>
);
}
app/dashboard/posts/page.tsx:
import { prisma } from '@/lib/db';
import { deletePost } from '@/lib/actions';
export default async function PostsManagePage() {
const posts = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } });
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">文章管理</h1>
<a href="/dashboard/posts/new" className="rounded bg-blue-600 px-4 py-2 text-white">
写文章
</a>
</div>
<table className="w-full">
<thead className="border-b">
<tr>
<th className="py-2 text-left">标题</th>
<th className="py-2 text-left">状态</th>
<th className="py-2">操作</th>
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id} className="border-b">
<td className="py-3">{post.title}</td>
<td className="py-3">{post.published ? '已发布' : '草稿'}</td>
<td className="py-3 text-center">
<form action={() => deletePost(post.id)}>
<button type="submit" className="text-red-600 hover:underline">删除</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
步骤 6:性能优化
next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
remotePatterns: [
{ protocol: 'https', hostname: '**' },
],
},
};
export default nextConfig;
全局 error.tsx:
'use client';
import { useEffect } from 'react';
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
console.error('全局错误:', error);
}, [error]);
return (
<div className="py-12 text-center">
<h2 className="text-2xl font-bold text-red-600">出错了</h2>
<p className="mt-2 text-gray-600">请稍后重试。</p>
<button onClick={reset} className="mt-4 rounded bg-blue-600 px-4 py-2 text-white">
重试
</button>
</div>
);
}
步骤 7:Docker 部署
Dockerfile:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npx prisma generate
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml(本地):
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:./dev.db
构建并运行:
docker-compose up --build
步骤 8:Vercel 部署
vercel
环境变量配置(Vercel Dashboard):
DATABASE_URL:用 SQLite(Vercel 文件系统只读)或切换到 PlanetScale/Neon
注意:Vercel 的 Serverless Function 文件系统是只读的(除
/tmp),SQLite 写入会失败。生产环境建议用 PostgreSQL(PlanetScale、Neon、Supabase)。
讲清楚要点
- 项目分层:
app/管路由和页面,components/管可复用 UI,lib/管工具函数和数据层,prisma/管数据库模型。 - 渲染策略组合:文章列表用 ISR(数据更新不频繁),文章详情用 SSR(SEO 实时),管理台用 SSR(数据实时)。
- Server Actions 简化表单:写文章、删除文章直接用 Action,不需要写 API 路由。变更后
revalidatePath刷新缓存。 - 生产数据库选择:本地/容器用 SQLite 最方便;Vercel/Serverless 用 PostgreSQL(PlanetScale/Neon/Supabase)。
- 错误处理全覆盖:路由级
error.tsx捕获渲染错误,not-found.tsx处理 404,Server Action 里 try/catch 处理业务错误。
你可能会懵的地方
-
本地 SQLite 能在 Vercel 跑吗? 不能。Vercel 的文件系统是只读的,SQLite 写入会失败。部署到 Vercel 时切换到 PostgreSQL。
-
Server Action 的
revalidatePath刷新的是哪里的缓存? 刷的是 Next.js 内部的 Full Route Cache 和 Data Cache。不是浏览器缓存,不是 CDN 缓存。 -
prisma generate需要在构建时执行吗? 需要。Prisma Client 是代码生成的,构建前要执行prisma generate生成类型和查询引擎。 -
为什么管理台不也用 ISR? 管理台数据实时性要求高,作者刚发布一篇文章,管理台应该立刻看到。ISR 有缓存延迟,不适合后台管理场景。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| Vercel 上部署 SQLite | 文件只读,写入失败 | 换 PostgreSQL(PlanetScale/Neon) |
忘记 prisma generate | 运行时找不到 Prisma Client | Dockerfile 里加 npx prisma generate |
| 全局样式在组件里导入 | 样式重复 | 只在根 layout 导入一次 |
| 管理台用 ISR | 数据有延迟 | 管理台用 SSR(force-dynamic) |
| 没有 error.tsx | 错误页面暴露堆栈 | 各层级配置 error.tsx |
卡点急救
- Docker 构建后 Prisma 报错:确认
npx prisma generate在npm run build之前执行;确认prisma目录被复制到最终镜像。 - Vercel 部署后数据库连接失败:检查
DATABASE_URL是否正确;确认用的是 PostgreSQL 不是 SQLite。 - Server Action 提交后页面不刷新:检查
revalidatePath的路径是否正确;检查是否被redirect跳到了缓存页面。 - 图片不显示:确认
next.config.ts的remotePatterns包含了图片域名;确认图片 URL 可访问。
面试常问
-
怎么设计一个 Next.js 博客系统? 路由层:
/blogISR 列表、/blog/[slug]SSR 详情、/dashboard管理台;数据层:Prisma + PostgreSQL;渲染层:Server Component 读数据、Client Component 做交互;部署:Vercel + PostgreSQL(PlanetScale)。 -
博客的 SEO 怎么做?
generateMetadata动态生成 title/description/og;SSG/SSR 保证 HTML 有内容;sitemap.ts和robots.ts自动生成;<Image>加 alt;语义化 HTML。 -
怎么保证发布后新文章立即可见? 列表页用 ISR +
revalidatePath('/blog')在发布时主动刷新;详情页用 SSR 无缓存。如果追求极致实时,列表页也用 SSR。
自测三问
- 为什么博客文章列表适合 ISR,而管理台不适合?
- Vercel 部署时 SQLite 为什么会失败?解决方案是什么?
revalidatePath和revalidateTag在这个项目里分别可以用在哪里?
小结一句话
一个完整的 Next.js 项目 = App Router 组织路由 + Server Component 获取数据 + Server Actions 处理变更 + CSS Modules/Tailwind 处理样式 + Prisma 连接数据库 + ISR/SSR 控制渲染 + Vercel/Docker 部署上线——前面 11 课的所有知识点,在这一课拧成一股绳。
延伸
- Next.js 官方示例仓库
- Prisma + Next.js 最佳实践
- 至此,Next.js 路线图全部 12 课已完成。建议回到 nextjs-roadmap 页面,逐节点标记已掌握,查漏补缺。