Skip to content

项目实战

🙋 学完了所有知识点,如何综合运用?让我们一起构建一个完整的博客应用。

项目概述#

功能需求#

技术栈#

类别技术
框架Next.js 15
数据库PostgreSQL + Prisma
认证Auth.js
样式Tailwind CSS
编辑器MDX
部署Vercel

项目初始化#

创建项目#

Terminal window
pnpm create next-app@latest my-blog --typescript --tailwind --eslint --app --src-dir
cd my-blog

安装依赖#

Terminal window
# 数据库
pnpm add prisma @prisma/client
pnpm add -D prisma
# 认证
pnpm add next-auth@beta @auth/prisma-adapter
# 工具
pnpm add zod date-fns
pnpm add -D @types/node
# Markdown
pnpm add @mdx-js/loader @mdx-js/react @next/mdx
pnpm add rehype-highlight remark-gfm

目录结构#

src/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ └── register/
│ ├── (main)/
│ │ ├── page.tsx
│ │ ├── blog/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/
│ │ └── dashboard/
│ ├── api/
│ │ └── auth/
│ ├── layout.tsx
│ └── globals.css
├── components/
│ ├── ui/
│ ├── blog/
│ └── layout/
├── lib/
│ ├── db.ts
│ ├── auth.ts
│ └── utils.ts
└── types/

数据库设计#

Prisma Schema#

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?
role Role @default(USER)
accounts Account[]
sessions Session[]
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
excerpt String?
coverImage String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
comments Comment[]
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published])
}
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
posts Post[]
}
model Comment {
id String @id @default(cuid())
content String
author User @relation(fields: [authorId], references: [id])
authorId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([postId])
}
enum Role {
USER
ADMIN
}
// Auth.js 相关表
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)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}

初始化数据库#

Terminal window
npx prisma migrate dev --name init
npx prisma generate

认证配置#

src/auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/db'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
session.user.id = user.id
session.user.role = user.role
return session
},
},
pages: {
signIn: '/login',
},
})
src/lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

布局组件#

根布局#

src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { SessionProvider } from 'next-auth/react'
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: 'My Blog',
template: '%s | My Blog',
},
description: '一个使用 Next.js 15 构建的博客',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<SessionProvider>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</SessionProvider>
</body>
</html>
)
}

头部组件#

src/components/layout/Header.tsx
import Link from 'next/link'
import { auth } from '@/auth'
import { UserMenu } from './UserMenu'
export async function Header() {
const session = await auth()
return (
<header className="border-b">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="text-xl font-bold">
My Blog
</Link>
<nav className="flex items-center gap-6">
<Link href="/blog" className="hover:text-gray-600">
博客
</Link>
{session?.user ? (
<>
<Link href="/dashboard" className="hover:text-gray-600">
仪表盘
</Link>
<UserMenu user={session.user} />
</>
) : (
<Link
href="/login"
className="bg-black text-white px-4 py-2 rounded"
>
登录
</Link>
)}
</nav>
</div>
</header>
)
}

文章功能#

文章列表页#

// src/app/(main)/blog/page.tsx
import Link from 'next/link'
import { prisma } from '@/lib/db'
import { formatDate } from '@/lib/utils'
async function getPosts() {
return prisma.post.findMany({
where: { published: true },
include: {
author: { select: { name: true, image: true } },
tags: true,
},
orderBy: { createdAt: 'desc' },
})
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">博客文章</h1>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<article key={post.id} className="border rounded-lg overflow-hidden">
{post.coverImage && (
<img
src={post.coverImage}
alt={post.title}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<div className="flex gap-2 mb-2">
{post.tags.map((tag) => (
<span
key={tag.id}
className="text-xs bg-gray-100 px-2 py-1 rounded"
>
{tag.name}
</span>
))}
</div>
<h2 className="text-xl font-semibold mb-2">
<Link
href={`/blog/${post.slug}`}
className="hover:text-blue-600"
>
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{post.author.name}</span>
<time>{formatDate(post.createdAt)}</time>
</div>
</div>
</article>
))}
</div>
</div>
)
}

文章详情页#

// src/app/(main)/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/db'
import { formatDate } from '@/lib/utils'
import { CommentSection } from '@/components/blog/CommentSection'
interface Props {
params: Promise<{ slug: string }>
}
async function getPost(slug: string) {
const post = await prisma.post.findUnique({
where: { slug, published: true },
include: {
author: { select: { name: true, image: true } },
tags: true,
comments: {
include: { author: { select: { name: true, image: true } } },
orderBy: { createdAt: 'desc' },
},
},
})
if (post) {
// 增加阅读量
await prisma.post.update({
where: { id: post.id },
data: { views: { increment: 1 } },
})
}
return post
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await prisma.post.findUnique({ where: { slug } })
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
}
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article className="container mx-auto px-4 py-8 max-w-3xl">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<img
src={post.author.image || '/default-avatar.png'}
alt={post.author.name || ''}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-medium">{post.author.name}</div>
<time className="text-sm">{formatDate(post.createdAt)}</time>
</div>
<span className="ml-auto">{post.views} 次阅读</span>
</div>
</header>
<div className="prose max-w-none mb-12">{post.content}</div>
<CommentSection postId={post.id} comments={post.comments} />
</article>
)
}

创建文章#

src/app/actions/posts.ts
'use server'
import { z } from 'zod'
import { auth } from '@/auth'
import { prisma } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
excerpt: z.string().max(500).optional(),
coverImage: z.string().url().optional(),
tags: z.array(z.string()),
published: z.boolean(),
})
function slugify(text: string) {
return text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
}
export async function createPost(prevState: any, formData: FormData) {
const session = await auth()
if (!session?.user) {
return { error: '请先登录' }
}
const data = {
title: formData.get('title'),
content: formData.get('content'),
excerpt: formData.get('excerpt'),
coverImage: formData.get('coverImage'),
tags: formData.getAll('tags'),
published: formData.get('published') === 'true',
}
const result = PostSchema.safeParse(data)
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
const slug = slugify(result.data.title)
const post = await prisma.post.create({
data: {
title: result.data.title,
slug,
content: result.data.content,
excerpt: result.data.excerpt,
coverImage: result.data.coverImage,
published: result.data.published,
authorId: session.user.id,
tags: {
connectOrCreate: result.data.tags.map((name) => ({
where: { name },
create: { name, slug: slugify(name) },
})),
},
},
})
revalidatePath('/blog')
revalidatePath('/dashboard')
redirect(`/blog/${post.slug}`)
}

评论功能#

src/components/blog/CommentSection.tsx
'use client'
import { useActionState } from 'react'
import { useSession } from 'next-auth/react'
import { addComment } from '@/app/actions/comments'
import { formatDate } from '@/lib/utils'
interface Comment {
id: string
content: string
createdAt: Date
author: { name: string | null; image: string | null }
}
export function CommentSection({
postId,
comments,
}: {
postId: string
comments: Comment[]
}) {
const { data: session } = useSession()
const [state, formAction, isPending] = useActionState(
addComment.bind(null, postId),
null
)
return (
<section>
<h2 className="text-2xl font-bold mb-6">评论 ({comments.length})</h2>
{session?.user ? (
<form action={formAction} className="mb-8">
<textarea
name="content"
placeholder="写下你的评论..."
rows={4}
className="w-full border rounded-lg p-4"
required
/>
{state?.error && <p className="text-red-500 mt-2">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="mt-2 bg-blue-600 text-white px-6 py-2 rounded"
>
{isPending ? '提交中...' : '发表评论'}
</button>
</form>
) : (
<p className="mb-8 text-gray-500">
<a href="/login" className="text-blue-600">
登录
</a>
后发表评论
</p>
)}
<div className="space-y-6">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-4">
<img
src={comment.author.image || '/default-avatar.png'}
alt={comment.author.name || ''}
className="w-10 h-10 rounded-full"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{comment.author.name}</span>
<time className="text-sm text-gray-500">
{formatDate(comment.createdAt)}
</time>
</div>
<p className="mt-1">{comment.content}</p>
</div>
</div>
))}
</div>
</section>
)
}

仪表盘#

// src/app/(main)/dashboard/page.tsx
import Link from 'next/link'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/db'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const posts = await prisma.post.findMany({
where: { authorId: session.user.id },
orderBy: { createdAt: 'desc' },
})
const stats = {
total: posts.length,
published: posts.filter((p) => p.published).length,
drafts: posts.filter((p) => !p.published).length,
views: posts.reduce((sum, p) => sum + p.views, 0),
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">仪表盘</h1>
<Link
href="/dashboard/new"
className="bg-blue-600 text-white px-4 py-2 rounded"
>
写文章
</Link>
</div>
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-white p-6 rounded-lg border">
<div className="text-gray-500">总文章</div>
<div className="text-3xl font-bold">{stats.total}</div>
</div>
<div className="bg-white p-6 rounded-lg border">
<div className="text-gray-500">已发布</div>
<div className="text-3xl font-bold">{stats.published}</div>
</div>
<div className="bg-white p-6 rounded-lg border">
<div className="text-gray-500">草稿</div>
<div className="text-3xl font-bold">{stats.drafts}</div>
</div>
<div className="bg-white p-6 rounded-lg border">
<div className="text-gray-500">总阅读</div>
<div className="text-3xl font-bold">{stats.views}</div>
</div>
</div>
<div className="bg-white rounded-lg border">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left">标题</th>
<th className="px-6 py-3 text-left">状态</th>
<th className="px-6 py-3 text-left">阅读</th>
<th className="px-6 py-3 text-left">操作</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id} className="border-t">
<td className="px-6 py-4">{post.title}</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 rounded text-sm ${
post.published
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{post.published ? '已发布' : '草稿'}
</span>
</td>
<td className="px-6 py-4">{post.views}</td>
<td className="px-6 py-4">
<Link
href={`/dashboard/edit/${post.id}`}
className="text-blue-600"
>
编辑
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

部署清单#

环境变量#

.env.production
DATABASE_URL="postgresql://..."
AUTH_SECRET="..."
GITHUB_ID="..."
GITHUB_SECRET="..."
NEXT_PUBLIC_APP_URL="https://yourdomain.com"

构建检查#

Terminal window
pnpm build
pnpm start

部署到 Vercel#

Terminal window
vercel --prod

总结#

恭喜你完成了 Next.js 15 全部课程!你已经学习了:

  1. 基础入门 - 项目创建、App Router、TypeScript
  2. 路由系统 - 动态路由、嵌套布局、中间件
  3. 渲染模式 - Server/Client Components、Streaming
  4. 数据处理 - 数据获取、缓存、Server Actions
  5. 优化性能 - 图片、字体、脚本、SEO
  6. 全栈开发 - 认证、数据库、测试、部署

继续探索,构建更多精彩的应用!

-EOF-