🙋 学完了所有知识点,如何综合运用?让我们一起构建一个完整的博客应用。
项目概述#
功能需求#
- 用户认证(GitHub OAuth)
- 文章 CRUD
- Markdown 编辑器
- 评论系统
- 标签分类
- 全文搜索
- 响应式设计
技术栈#
| 类别 | 技术 |
|---|---|
| 框架 | Next.js 15 |
| 数据库 | PostgreSQL + Prisma |
| 认证 | Auth.js |
| 样式 | Tailwind CSS |
| 编辑器 | MDX |
| 部署 | Vercel |
项目初始化#
创建项目#
pnpm create next-app@latest my-blog --typescript --tailwind --eslint --app --src-dircd my-blog安装依赖#
# 数据库pnpm add prisma @prisma/clientpnpm add -D prisma
# 认证pnpm add next-auth@beta @auth/prisma-adapter
# 工具pnpm add zod date-fnspnpm add -D @types/node
# Markdownpnpm add @mdx-js/loader @mdx-js/react @next/mdxpnpm 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#
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])}初始化数据库#
npx prisma migrate dev --name initnpx prisma generate认证配置#
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', },})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}布局组件#
根布局#
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> )}头部组件#
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.tsximport 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.tsximport { 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> )}创建文章#
'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}`)}评论功能#
'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.tsximport 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> )}部署清单#
环境变量#
DATABASE_URL="postgresql://..."AUTH_SECRET="..."GITHUB_ID="..."GITHUB_SECRET="..."NEXT_PUBLIC_APP_URL="https://yourdomain.com"构建检查#
pnpm buildpnpm start部署到 Vercel#
vercel --prod总结#
恭喜你完成了 Next.js 15 全部课程!你已经学习了:
- 基础入门 - 项目创建、App Router、TypeScript
- 路由系统 - 动态路由、嵌套布局、中间件
- 渲染模式 - Server/Client Components、Streaming
- 数据处理 - 数据获取、缓存、Server Actions
- 优化性能 - 图片、字体、脚本、SEO
- 全栈开发 - 认证、数据库、测试、部署
继续探索,构建更多精彩的应用!
-EOF-