Next.js: 渲染策略:SSR、SSG 与 ISR
最后更新:2026-08-26
Next.js 的渲染策略让你在"构建时就绪"和"请求时最新"之间自由选择——关键是选对模式。
1. 你将学到
- SSR 流式渲染与 Suspense 边界的即时首字节
- SSG 静态站点生成与
generateStaticParams()动态路由预生成 - ISR 增量静态再生与
revalidate时间窗口 force-dynamicvsforce-static路由段配置revalidatePath()/revalidateTag()按需触发 ISR
2. 一个技术主管的真实故事
(1) 痛点:CMS 页面加载 6 秒,运营每天更新 50 次
Charlie 是 TaskFlow 团队的技术主管。公司的电商 CMS 有 10,000 个产品页面,每个包含描述、价格、库存、图片。最糟的是:
| 问题 | 数据 |
|---|---|
| 单页面 SSR 响应时间 | 6 秒(每次请求都查数据库) |
| 运营每日更新 | 50+ 次(价格/库存/活动) |
| 服务器 CPU | 持续 85%+ |
| 缓存策略 | ❌ 无——每次都是实时渲染 |
运营团队每隔 15 分钟更新一次价格,但 SSR 每次都要重新查数据库——10,000 个页面 × 6 秒 = 60,000 秒/天的 CPU 开销。
(2) ISR 的解法
用 ISR 构建时生成静态页,每隔 N 秒重新验证,按需立即刷新。
// app/products/[id]/page.tsx — ISR 增量静态再生
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { id: true } })
return products.map(p => ({ id: String(p.id) }))
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: Number(params.id) } })
return <ProductView product={product} />
}
// 路由段配置
export const revalidate = 300 // 5 分钟重新验证一次
(3) 收益
| 维度 | 纯 SSR | ISR |
|---|---|---|
| 响应时间 | 6 秒 | < 50 ms(静态 HTML) |
| 服务器 CPU | 85% | < 10% |
| 运营更新 | ❌ 必须等 6 秒 | ✅ 即时(按需 revalidate) |
| CDN 缓存 | ❌ 不可 | ✅ 全页面可缓存 |
| 数据库 QPS | 10,000/小时 | ~50/小时 |
3. SSR 流式渲染
SSR(Server-Side Rendering)在每次请求时在服务端渲染 HTML。Next.js 16 使用 React 18 的流式 SSR——不需要等待整个页面渲染完成,而是将页面拆分为多个 Suspense 边界,逐个发送到客户端。
sequenceDiagram
participant Client as Browser
participant Server as Next.js Server
participant DB as Database
Client->>Server: GET /dashboard
Server->>Client: 发送静态 HTML Shell(immediate)
Server->>DB: 查询数据(并行)
DB-->>Server: 返回部分数据
Server->>Client: 流式发送 <Suspense> 边界 1
DB-->>Server: 返回更多数据
Server->>Client: 流式发送 <Suspense> 边界 2
Client->>Client: 逐步渲染内容
| 配置 | 写法 | 行为 |
|---|---|---|
| 默认(SSR 流式) | 无配置 | 页面动态渲染,支持 Suspense 流式输出 |
force-dynamic |
export const dynamic = 'force-dynamic' |
强制每次请求重新渲染,禁用缓存 |
force-static |
export const dynamic = 'force-static' |
强制构建时静态化,禁用动态行为 |
(1) dynamic = 'force-dynamic'
确保页面在每个请求时都重新生成,适合高度个性化的数据(用户仪表盘、购物车)。
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'
export default async function DashboardPage() {
const user = await getCurrentUser() // 每次请求都获取最新
return <DashboardView user={user} />
}
(2) dynamic = 'force-static'
强制页面在构建时静态化,即使它包含动态函数也会被预先计算。
// app/about/page.tsx
export const dynamic = 'force-static'
export default async function AboutPage() {
const version = await getVersion() // 构建时计算一次
return <div>Version: {version}</div>
}
▶ 示例:SSR 流式加载体验(难度⭐)
// app/streaming-demo/page.tsx
import { Suspense } from 'react'
export default function StreamingDemoPage() {
return (
<div>
<h1>Streaming SSR Demo</h1>
<p>This text appears immediately (static shell).</p>
<Suspense fallback={<div>Loading slow data...</div>}>
<SlowComponent delay={3000} />
</Suspense>
<Suspense fallback={<div>Loading fast data...</div>}>
<SlowComponent delay={1000} />
</Suspense>
</div>
)
}
async function SlowComponent({ delay }: { delay: number }) {
await new Promise(resolve => setTimeout(resolve, delay))
return <div>Loaded after {delay}ms</div>
}
4. SSG 静态站点生成与 generateStaticParams
SSG(Static Site Generation)在构建时一次性生成所有 HTML 页面,适合内容很少变化的场景(博客、文档、营销页)。
(1) 静态页面
// app/docs/page.tsx — 构建时生成
export default async function DocsPage() {
const docs = await fetch('https://cms.example.com/docs', {
cache: 'force-cache' // 构建时获取,持久缓存
}).then(r => r.json())
return <ArticleView content={docs} />
}
(2) generateStaticParams() 动态路由预生成
用于动态路由 [id]、[slug] 等页面,返回所有可能的路径参数,构建时一次性生成。
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts').then(r => r.json())
return posts.map((post: any) => ({ slug: post.slug })) // → /blog/hello-world, /blog/nextjs-guide ...
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`).then(r => r.json())
return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}
▶ 示例:SSG 动态路由预生成(难度⭐⭐)
// app/products/[id]/page.tsx
type Product = { id: number; title: string; price: number }
export async function generateStaticParams() {
const products: Product[] = await fetch('https://fakestoreapi.com/products').then(r => r.json())
return products.slice(0, 5).map(p => ({ id: String(p.id) }))
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product: Product = await fetch(`https://fakestoreapi.com/products/${params.id}`).then(r => r.json())
return (
<div>
<h1>{product.title}</h1>
<p>Price: ${product.price}</p>
</div>
)
}
输出:
构建日志:
✓ Generating static pages (5/5) /products/1 /products/2 /products/3 /products/4 /products/5
5. ISR 增量静态再生
ISR(Incremental Static Regeneration)是 SSG 和 SSR 的中间地带:构建时生成静态页面,在 revalidate 窗口过期后后台触发重新生成,用户始终看到缓存页面。
graph LR
A[构建] --> B[生成静态 HTML]
B --> C[CDN 缓存]
C --> D[用户请求]
D --> E{revalidate<br/>已过期?}
E -->|否| F[返回缓存]
E -->|是| G[返回缓存 + 后台重新生成]
G --> B
| 模式 | revalidate | 行为 | 首次访问 | 更新延迟 |
|---|---|---|---|---|
| SSG | 无 | 只构建时生成 | 即时 | 需重新构建 |
| ISR | revalidate: 60 |
60 秒后后台刷新 | 即时 | ≤ 60 秒 |
| SSR | dynamic: 'force-dynamic' |
每次请求刷新 | 动态生成 | 实时 |
(1) revalidate 路由段配置
// app/posts/[id]/page.tsx
export const revalidate = 3600 // 1 小时重新验证一次
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.id}`, {
next: { revalidate: 3600 } // 也兼容 fetch 级别
}).then(r => r.json())
return <PostView post={post} />
}
(2) On-Demand ISR(按需重新验证)
使用 revalidatePath() 或 revalidateTag() 在数据变更时立即触发重新生成。
// app/admin/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(formData: FormData) {
const id = formData.get('id') as string
await db.post.update({ where: { id: Number(id) }, data: { title: formData.get('title') as string } })
revalidatePath(`/posts/${id}`) // 精确刷新单篇文章
revalidatePath('/posts') // 刷新列表页
revalidateTag('posts') // 刷新所有带 posts 标签的缓存
}
▶ 示例:ISR 时间窗口(难度⭐⭐)
// app/isr-demo/page.tsx
export const revalidate = 30 // 30 秒重新验证
export default async function IsrDemoPage() {
const time = await fetch('http://worldtimeapi.org/api/timezone/Asia/Shanghai', {
next: { tags: ['time'] }
}).then(r => r.json())
return (
<div>
<h1>ISR Demo — Revalidate every 30s</h1>
<p>Current time: {time.datetime}</p>
<p>Generated at: {new Date().toISOString()}</p>
</div>
)
}
▶ 示例:按需重新验证 API Route(难度⭐⭐⭐)
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
const body = await request.json()
if (body.type === 'path') {
revalidatePath(body.path)
} else if (body.type === 'tag') {
revalidateTag(body.tag)
}
return NextResponse.json({ revalidated: true })
}
# 从 CMS Webhook 调用
curl -X POST https://example.com/api/revalidate \
-H "x-revalidate-secret: your-secret" \
-H "Content-Type: application/json" \
-d '{"type": "tag", "tag": "posts"}'
▶ 示例:dynamic = 'force-static' 设置静态页(难度⭐)
// app/static-page/page.tsx — 强制静态
export const dynamic = 'force-static'
export default async function StaticPage() {
const time = new Date().toISOString()
return (
<div>
<h1>Static Page (built at build time)</h1>
<p>This timestamp is fixed: {time}</p>
<p>Refresh the page — the time never changes.</p>
</div>
)
}
// app/dynamic-page/page.tsx — 强制动态
export const dynamic = 'force-dynamic'
export default async function DynamicPage() {
const time = new Date().toISOString()
return (
<div>
<h1>Dynamic Page (rendered per request)</h1>
<p>This timestamp updates every refresh: {time}</p>
</div>
)
}
输出对比:
Static page: Timestamp always "2026-07-06T10:00:00.000Z" ← 不变
Dynamic page: Timestamp changes every refresh ← 每次不同
6. 完整示例:三模式博客系统
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>{children}</div>
}
// app/blog/page.tsx — 博客列表(SSG + ISR)
export const revalidate = 300 // 5 分钟 ISR
export default async function BlogListPage() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { tags: ['blog-posts'] }
}).then(r => r.json())
return (
<div>
<h1>Blog</h1>
<ul>{posts.slice(0, 10).map((p: any) => (
<li key={p.id}><a href={`/blog/${p.id}`}>{p.title}</a></li>
))}</ul>
<p>Last generated: {new Date().toISOString()}</p>
</div>
)
}
// app/blog/[id]/page.tsx — 文章详情(SSG + ISR)
export async function generateStaticParams() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json())
return posts.slice(0, 10).map((p: any) => ({ id: String(p.id) }))
}
export default async function BlogPostPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, {
next: { tags: [`post-${params.id}`] }
}).then(r => r.json())
const comments = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}/comments`, {
cache: 'no-store'
}).then(r => r.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<h2>Comments ({comments.length})</h2>
<ul>{comments.map((c: any) => (
<li key={c.id}><strong>{c.name}:</strong> {c.body}</li>
))}</ul>
</article>
)
}
// app/blog/actions.ts — 按需刷新
'use server'
import { revalidateTag } from 'next/cache'
export async function refreshBlog() {
revalidateTag('blog-posts')
}
// app/blog/[id]/admin/page.tsx — 管理后台(SSR)
export const dynamic = 'force-dynamic'
export default async function AdminPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(r => r.json())
return (
<form action={async (fd) => {
'use server'
await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, { method: 'PATCH', body: JSON.stringify({ title: fd.get('title') }) })
revalidateTag(`post-${params.id}`)
}}>
<input name="title" defaultValue={post.title} />
<button type="submit">Update</button>
</form>
)
}
❓ 常见问题
export const dynamicParams = true(默认)允许未预生成路径动态渲染。<Suspense> 边界是一个独立的流式输出单元。服务端会先发送静态 HTML shell,然后逐步发送每个 Suspense 边界的内容。用户看到的是"渐进填充"效果——不需要等待所有数据就绪。📖 小节
- SSR 流式渲染通过 Suspense 边界实现即时首字节 + 渐进填充
dynamic = 'force-static'/'force-dynamic'控制页面渲染模式generateStaticParams()构建时预生成动态路由的所有页面组合- ISR 通过
revalidate = N实现静态页面的自动增量更新 - On-Demand ISR 使用
revalidatePath()/revalidateTag()在数据变更时立即刷新 - 渲染策略选型金字塔:SSG(不变)→ ISR(偶尔变)→ SSR(实时变)→ PPR(部分变)
📝 作业
-
基础题(⭐):创建一个
app/ssg-demo/[id]/page.tsx,使用generateStaticParams预生成/products/1到/products/5五个静态页面(数据来自 FakeStore API),构建后验证每个页面的 HTML 是静态文件。 -
进阶题(⭐⭐):创建一个 ISR 时间展示页面
app/isr-clock/page.tsx,设置revalidate = 15,使用 World Time API 显示当前时间。每 15 秒刷新验证时间是否更新。添加一个 Server ActionforceRefresh()使用revalidatePath()立即刷新。 -
挑战题(⭐⭐⭐):构建一个完整的博客系统:
app/blog/page.tsx(ISR 列表,revalidate 120 秒)、app/blog/[slug]/page.tsx(SEO 元数据 + JSON-LD 结构化数据)、app/api/revalidate/route.ts(Webhook 端点接收 CMS 变更通知,按 tag 刷新缓存)。提供一个示例 Webhook 调用脚本。