React18+Next.js全栈实战:从SSR到API路由
1. 真实场景:一个博客项目把我逼到崩溃
2023年底,我负责公司技术博客平台重构。原有方案是Create React App作为前端,Express提供REST API,数据库PostgreSQL。首屏加载时间在3G网络下实测3.2秒,Lighthouse SEO分数只有35分(因为SPA爬虫拿不到内容)。用户跳出率高达60%。
我尝试过预渲染(prerender.io),每月额外支出$50,效果还不太稳定。最终决定迁移到Next.js全栈方案,用React18 Server Components + App Router。结果:首屏1.1秒,SEO 98分,开发效率提升一倍。本文就是这次迁移的完整复盘。
2. 方案对比:传统SPA vs Next.js全栈
| 维度 | CRA + Express (传统SPA) | Next.js App Router (全栈) |
|---|---|---|
| 首屏加载时间 (3G) | 3.2s | 1.1s |
| Lighthouse SEO分数 | 35 | 98 |
| 数据预取方式 | useEffect + fetch | async component + fetch |
| 路由定义 | react-router v6 | 文件系统路由 |
| API层 | 单独Express项目 | 同一项目 API Routes |
| 部署配置 | 前端静态托管 + Node后端 | 单一Node服务 (Vercel/Nginx) |
| 开发效率 (从0到上线) | 约2周 | 约5天 |
为什么传统SPA会慢?三个核心原因
- 客户端渲染 (CSR):所有内容需要下载完JS Bundle再渲染,爬虫只能看到一个空div。
- 额外的网络请求:浏览器先加载HTML -> JS -> 执行fetch获取数据 -> 渲染内容。至少3次往返。
- 没有流式渲染:必须等所有数据齐全才能显示。
Next.js App Router使用React Server Components,所有数据获取和渲染在服务端完成,只发送纯HTML+少量交互JS给客户端。同时支持流式渲染(Streaming)和增量静态生成(ISR)。
3. 完整代码实现:从零搭建博客全栈应用
以下代码基于 Node 18.17.0, Next.js 14.1.0, React 18.2.0, Prisma 5.8.0, SQLite 3(生产可换PostgreSQL)。完整项目可运行。
3.1 项目初始化
npx create-next-app@14 next-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd next-blog
npm install prisma @prisma/client
npx prisma init
3.2 数据库 Schema (Prisma)
在 prisma/schema.prisma 中定义。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id Int @id @default(autoincrement())
title String
content String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[] // 简化,不建立关联
}
运行 npx prisma migrate dev --name init 创建数据库和表。
添加种子数据:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const post = await prisma.post.create({
data: {
title: 'Hello Next.js Fullstack',
content: '这是第一篇文章的内容,讲解React Server Components。',
slug: 'hello-nextjs'
}
})
console.log('Created post:', post)
}
main()
.catch(e => console.error(e))
.finally(() => prisma.$disconnect())
在 package.json 添加 seed 脚本:"prisma": { "seed": "tsx prisma/seed.ts" },然后 npx prisma db seed。
3.3 全局数据库连接 (lib/db.ts)
// 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
3.4 文章列表页 (SSR + Streaming)
// src/app/page.tsx
import Link from 'next/link'
import { prisma } from '@/lib/db'
// App Router 直接 async 组件
export default async function Home() {
// 服务端获取数据
const posts = await prisma.post.findMany({
orderBy: { createdAt: 'desc' }
})
return (
博客列表
{/* 使用流式渲染?这里简单演示同步渲染 */}
{posts.length === 0 ? (
暂无文章
) : (
{posts.map(post => (
-
{post.title}
发布于 {new Date(post.createdAt).toLocaleDateString('zh-CN')}
))}
)}
)
}
/* 如果希望流式加载,可以用 Suspense + 子组件,但本示例一切从简 */
3.5 文章详情页 (SSG + ISR)
// src/app/post/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/db'
// 生成静态路径 (增量)
export async function generateStaticParams() {
const posts = await prisma.post.findMany({ select: { slug: true } })
return posts.map(post => ({ slug: post.slug }))
}
// 页面组件
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
})
if (!post) notFound()
return (
{post.content}
)
}
// ISR 增量重新生成:每60秒重新验证
export const revalidate = 60
3.6 API 路由:创建文章 (POST)
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export async function POST(request: Request) {
try {
const body = await request.json()
const { title, content, slug } = body
if (!title || !content || !slug) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
}
const post = await prisma.post.create({
data: { title, content, slug }
})
return NextResponse.json(post, { status: 201 })
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
export async function GET() {
const posts = await prisma.post.findMany()
return NextResponse.json(posts)
}
3.7 客户端组件示例:创建文章表单
// src/app/create/page.tsx (客户端组件)
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function CreatePost() {
const router = useRouter()
const [form, setForm] = useState({ title: '', content: '', slug: '' })
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
if (res.ok) {
router.push('/')
router.refresh()
}
} finally {
setLoading(false)
}
}
return (
)
}
4. 效果数据:迁移前后对比
我在同一台服务器(4C8G,CentOS 7,Nginx反向代理)上部署了两个版本,使用Lighthouse模拟移动端3G网络。
| 指标 | 传统SPA (CRA+Express) | Next.js App Router | 提升幅度 |
|---|---|---|---|
| First Contentful Paint (FCP) | 2.8s | 0.8s | 71% |
| Largest Contentful Paint (LCP) | 3.2s | 1.1s | 66% |
| Time to Interactive (TTI) | 4.5s | 1.5s | 67% |
| SEO 分数 (Lighthouse) | 35 | 98 | +63分 |
| 构建时间 (production) | 45s (CRA) | 22s (Next.js) | 51% |
| JS Bundle 大小 (首屏) | 187KB | 42KB (仅交互所需) | 78% |
注意:Next.js 客户端组件只有 create/page.tsx 是客户端,其余全为服务端组件,因此首屏JS极小。
5. 避坑指南:实际遇到过的8个坑
坑1: App Router 下不存在 `getServerSideProps`
我刚开始迁移时,一直习惯写 `export async function getServerSideProps`。在App Router中,所有页面组件本身就是 async 函数,直接在其中 fetch 或查数据库即可。没有 context 参数,需要用 params 和 searchParams 作为组件 props。
正确写法:如上 PostPage 直接接收 { params }。
坑2: revalidate 时间随意设置导致数据不一致
我把文章详情页 ISR 的 revalidate 设为 10 秒,结果后台改了文章内容,用户仍然看到旧内容长达10秒。对于内容管理系统,建议使用 revalidate = 0 或按需重新验证(revalidatePath)。生产环境我改成了按需触发:在 API 路由创建/更新后调用 revalidatePath('/') 和 revalidatePath('/post/[slug]')。
坑3: 图片外部域名未配置导致加载失败
使用 next/image 加载外部图片时,必须在 next.config.js 中添加 remotePatterns。否则图片无法显示,控制台报 403。我的配置:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
// 其他域名
],
},
}
module.exports = nextConfig
坑4: 中间件 middleware.ts 不能使用 Prisma
我试图在中间件中读取用户数据做权限判断,但中间件运行在 Edge Runtime,不支持 Prisma(需要 Node API)。必须改用 @vercel/edge-config 或者将判断逻辑放在布局/页面中。
坑5: params 是 Promise 吗?
Next.js 14.1 中 params 有时是异步的(特别是动态路由嵌套)。官方文档说在组件中直接使用 params.slug 即可,但如果你试图在 generateMetadata 中使用,需要 params: Promise<{ slug: string }>,然后 await。我踩过一次,导致 build 失败。
正确写法:
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
...
}
坑6: 客户端组件与服务端组件混合时的数据流
我试图在服务端组件中把一个大型对象传给客户端组件作为 props,导致序列化错误(Function 无法序列化)。必须确保 props 是 JSON 可序列化的。如果需要在客户端使用服务端数据,推荐通过 fetch API 或从服务端组件传递给客户端的 prop 只能是原始类型或简单对象。
坑7: API 路由与 Prisma 的 Connection Pool 超时
在高并发下,Prisma 默认连接池(9个连接)可能被耗尽。我遇到过 Timed out fetching a new connection from the pool 错误。解决办法:增加连接池大小或者使用 prisma $transaction 减少连接占用。我在生产环境将 SQLite 换成了 PostgreSQL 并设置 connection_limit=20。
// lib/db.ts 增加连接池选项(PostgreSQL)
const prisma = new PrismaClient({
log: ['query'],
datasources: {
db: {
url: process.env.DATABASE_URL
}
},
// 连接池配置在 Prisma 5 中使用 connection_limit 参数在 URL 中:
// DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20"
})
坑8: 路由冲突:/api/posts 和 /posts 页面
我同时建立了 app/api/posts/route.ts 和 app/posts/page.tsx。Next.js 不会混淆,因为 api/ 是约定。但我之前复制粘贴目录时把 page.tsx 放到了 api/posts/ 下,导致 route.ts 不生效。记住 api/ 下只能有 route.ts,不能有 page.tsx。
6. 总结:这套方案适合谁?
如果你的项目需要 SEO、首屏快、开发效率高,并且你不是极端偏好客户端渲染(如仪表盘、画板),那么 Next.js App Router 是当前最优解。本文整套代码可以直接克隆运行,实际生产只需替换 SQLite 为 PostgreSQL 并配置环境变量。
建议使用 Next.js 14.1+ 和 React 18.2+,App Router 已经稳定。部署可以用 Vercel(零配置)或者自己的服务器(通过 Docker + PM2)。