Next.js App Router 渲染全解:SSG、ISR、SSR 怎么选、怎么配
App Router 的渲染结果由取数与缓存策略决定:构建期生成是 SSG,定期重建是 ISR,请求时现取是 SSR。本文讲清 Next.js 15 起 fetch 默认不缓存等关键变化、路由段配置、按需重新验证、流式渲染与选型表。
- Next.js
- 概念
- 进阶
- 更新于 2026-10-31
接手一个陌生的 Next.js 模块时,最难判断的往往不是代码怎么写,而是这条路由到底在什么时机渲染。同样是 app/posts/[slug]/page.tsx,有人觉得它是静态页,有人以为是每次现算,吵到最后谁也说服不了谁——因为在 App Router 里,渲染方式根本不是一个可以手动拨动的开关,它是「这份数据什么时候被取」所决定的结果。
本文按「机制 → 动手 → 验证 → 排错」的顺序,把 App Router 的三种服务端渲染形态讲透:什么条件下会变成 SSG、什么条件下退化成 ISR、什么时候只能老老实实做 SSR;再把路由段配置、按需重新验证、流式渲染、Next.js 16 的新模型串起来。读完你应该能对着 next build 的路由表,反推出每条路由的渲染形态,并在改动缓存策略后自己验证它真的生效了。
需要先声明版本前提:本文以 Next.js 16(未启用 Cache Components) 为主要口径,Next.js 15 的差异会在涉及缓存默认值的地方单独标出;Next.js 16 启用 cacheComponents 后的新模型单独放在第八节。
一、先把术语钉在机制上:渲染形态是取数时机的副产物
先把三个词说清楚,后面所有讨论都建立在这个区分上。
SSG(Static Site Generation,静态生成):页面 HTML 在构建期一次性生成好,部署后一直就是这份产物。请求到达时,服务器不需要跑任何渲染逻辑,直接把文件吐出去。
ISR(Incremental Static Regeneration,增量静态再生):同样是预渲染的 HTML,但带一个有效期。有效期内的请求直接命中缓存;一旦过期,请求先拿到旧内容,同时后台异步触发这个页面的重新生成,生成成功后替换缓存。也就是常说的 stale-while-revalidate:用户永远不等待重建,只有「被访问到的页面」会重建,而不是全站重新构建。
SSR(Server-Side Rendering,服务端渲染):页面 HTML 在每次请求时由服务器现场渲染并返回。内容实时,但每个请求都要付出一次渲染成本。
这三者的差别不在「能不能被搜索引擎收录」——它们对爬虫是同一阵营,都把完整 HTML 交给爬虫。差别只在内容新鲜度与服务器压力这两件事上。渲染方式与 SEO 的关系,同批的《服务端渲染与 SEO:为什么源码里必须有正文》里有完整展开。
判断一条路由属于哪种形态,不要靠猜,看判据:
| 形态 | HTML 生成时机 | 判据(满足其一即成立) | 数据新鲜度 | 请求期算力开销 |
|---|---|---|---|---|
| SSG | 构建期一次 | 页面不依赖任何请求期数据;动态路由用 generateStaticParams 把路径在构建期枚举出来 |
直到下次构建 | 几乎为零 |
| ISR | 构建期生成 + 到期后台重建 | 路由段 export const revalidate = n,或单个取数带 revalidate / cache: 'force-cache' |
最多滞后一个周期 | 仅重建时 |
| SSR | 每次请求 | 用了 cookies() / headers() / searchParams 等请求期 API,或 export const dynamic = 'force-dynamic' |
实时 | 每次都算 |
最容易搞混的一点:一段路由到底是不是 ISR,不取决于你在 page.tsx 里写了什么,而取决于取数时的缓存策略。如果页面上所有 fetch 都没写缓存策略,而 Next.js 15 之后 fetch 默认不缓存,那这条路由在构建期就会被判定为动态,直接变成 SSR;你却在文件顶部写了 export const revalidate = 60,看起来像 ISR,实际一点作用都没有。
反过来,如果 revalidate 写对了,但底层数据源压根没走 Next.js 的缓存层(比如直接查了一个不支持缓存的外部服务),页面每次重建都拿到最新数据,那它虽然是 ISR 形态,内容新鲜度却接近 SSR——缓存策略写在路由上,数据策略写在别处,两边对不上。
二、Next.js 15 起的第一个坑:fetch 默认不缓存
这是升级时最容易出事、也最容易被漏掉的一条。
Next.js 15 起,fetch 请求默认不缓存。 Next.js 14 及更早版本默认缓存。要缓存必须显式写出来。
| 写法 | 语义 | 适合的场景 |
|---|---|---|
fetch(url, { cache: 'no-store' }) |
每次请求都现取,明确不缓存 | 实时数据、用户相关数据 |
fetch(url, { cache: 'force-cache' }) |
长期缓存;命中就返回,未命中或过期则回源并更新缓存 | 基本不变的资源 |
fetch(url, { next: { revalidate: 60 } }) |
缓存,并按秒重新验证 | 内容站列表页、详情页 |
fetch(url, { next: { tags: ['posts'] } }) |
缓存并打标签,供 revalidateTag 精确失效 |
需要按事件刷新的数据 |
还有两个容易踩的细节:
cache 与 next.revalidate 冲突时,两个选项都会被忽略,开发模式下终端会打印警告。所以 fetch(url, { cache: 'no-store', next: { revalidate: 60 } }) 是错的写法,别写。
同一个 URL 在同一条路由里出现两次且 revalidate 不同,取较小的那个。 这条规则会向下传导:单个 fetch 的 revalidate 比路由默认值更小,整条路由的再验证周期就跟着变小。
顺带一个容易被忽略的点:fetch 的 GET 请求在一次服务端渲染轮次内是自动去重的。同一个 URL 在多个服务端组件、layout、page、generateStaticParams 里各写一遍,框架只会真正发一次。但这个去重只活在单次渲染里,和跨请求的持久缓存是两回事——去重不缓存,缓存不一定去重。
三、服务端组件与客户端组件的边界
App Router 默认所有 layout 和 page 都是服务端组件:可以声明成 async、可以直接查库或发 fetch,这些代码不会发到浏览器。只有在文件顶部加了 'use client' 的组件才在浏览器里运行,负责交互和状态。
这个边界直接决定了渲染形态怎么算:
- 服务端组件里取数 → 数据参与服务端渲染,结果受缓存策略影响,直接决定这条路由是 SSG / ISR / SSR。
- 客户端组件里取数 → 服务端只输出一份外壳(HTML + RSC Payload),数据由浏览器运行时去取。这条路由的 HTML 形态跟客户端取数无关,它取决于外壳里还有没有别的东西依赖请求期数据。
边界方向是单向的:客户端组件可以嵌套服务端组件(把服务端组件当作 children 传进去,服务端组件照样在服务端渲染),反过来只能通过 props 传数据。
// app/posts/[slug]/page.tsx —— 服务端组件:直接 await 取数
import { getCachedPost } from '@/lib/data'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getCachedPost(slug)
return <h1>{post.title}</h1>
}
// app/posts/[slug]/like-button.tsx —— 客户端组件:负责交互
'use client'
import { useState } from 'react'
export default function LikeButton({ likes }: { likes: number }) {
const [count, setCount] = useState(likes)
return (
<button type="button" onClick={() => setCount(count + 1)}>
点赞 {count}
</button>
)
}
两个都要记住的工程约束:
- 传给客户端组件的 props 必须可序列化。 函数、类实例、
Symbol都传不过去,需要传的是对象、数组、原始值。 - 别把服务端专属代码带进客户端模块图。 一旦一个文件标了
'use client',它 import 的模块和它直接渲染的组件都会进客户端包。密钥要用server-only包做硬隔离,否则一次误 import 就可能把密钥打进浏览器产物。
// lib/cms.ts —— 顶部加这一行,客户端组件一旦误引就直接构建报错
import 'server-only'
四、动手:从零搭一个同时演示三种形态的内容站
下面这套代码是可以直接跑起来的最小工程。它刻意把三种形态放在同一个项目里:同一个数据源、同一套取数层,只是路由段的配置不同。跑通之后,你对「配置怎么影响渲染形态」的理解会比看十篇文章都扎实。
4.1 工程骨架
{
"name": "next-rendering-demo",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.6.0"
}
}
// next.config.ts —— 本工程不启用 Cache Components,走「路由段配置」这套模型
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactStrictMode: true,
// 打开后可以看到哪些 fetch 被缓存、哪些被跳过
logging: { fetches: { fullUrl: true } },
}
export default nextConfig
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
4.2 数据层:把「取数」和「缓存」分开写
这是整个工程最关键的一份文件。设计原则只有一条:页面不直接摸数据源,所有取数都经过数据层。这样你换数据源、调整缓存策略时,页面代码一行都不用改。
// lib/cms.ts —— 模拟一个外部 CMS:进程内数据 + 固定延迟
import 'server-only'
export interface Post {
slug: string
title: string
summary: string
body: string
updatedAt: string
}
const posts: Post[] = [
{
slug: 'app-router-rendering',
title: 'App Router 的渲染是怎么定的',
summary: '渲染形态不是配置出来的,是取数时机决定的。',
body: '构建期能取完的数据就是 SSG;能取完但会过期的就是 ISR;取不完的才是 SSR。',
updatedAt: '2026-10-18',
},
{
slug: 'fetch-cache-defaults',
title: 'fetch 的缓存默认值在 Next.js 15 变了',
summary: '默认不缓存,要缓存必须显式写出来。',
body: 'Next.js 15 起 fetch 默认不缓存,Next.js 14 及更早默认缓存。迁移时先查这一条。',
updatedAt: '2026-10-22',
},
{
slug: 'on-demand-revalidation',
title: '按需重新验证:把更新时机交给业务事件',
summary: 'revalidatePath 精确到路径,revalidateTag 精确到数据。',
body: '内容在编辑器里保存的那一刻就触发失效,比等一个固定周期更准。',
updatedAt: '2026-10-29',
},
]
// 固定延迟,方便肉眼观察「命中缓存」和「现取」的区别
const LATENCY_MS = 300
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function listPosts(): Promise<Post[]> {
await sleep(LATENCY_MS)
return posts
}
export async function listPostSlugs(): Promise<string[]> {
await sleep(LATENCY_MS)
return posts.map((post) => post.slug)
}
export async function getPost(slug: string): Promise<Post | null> {
await sleep(LATENCY_MS)
return posts.find((post) => post.slug === slug) ?? null
}
// 模拟「后台保存文章」,供 Server Action 调用
export async function savePost(slug: string, title: string): Promise<void> {
const post = posts.find((item) => item.slug === slug)
if (!post) throw new Error(`文章不存在:${slug}`)
post.title = title
post.updatedAt = new Date().toISOString().slice(0, 10)
}
// lib/data.ts —— 取数层:缓存策略只在这里出现
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
import { getPost, listPostSlugs, listPosts } from './cms'
/**
* 非 fetch 的数据访问要自己做缓存:用 unstable_cache 包一层。
* 第三个参数支持 tags(按需失效的钥匙)和 revalidate(秒)。
* 注意:数据层的 revalidate 要 ≤ 路由层的 revalidate,
* 否则页面每 60 秒重建,读到的却还是 3600 秒前的旧数据。
*/
export const getCachedPost = unstable_cache(
async (slug: string) => getPost(slug),
['post-by-slug'], // 缓存键前缀;函数入参会自动并入键
{ tags: ['posts'], revalidate: 60 },
)
export const getCachedPostList = unstable_cache(
async () => listPosts(),
['post-list'],
{ tags: ['posts'], revalidate: 60 },
)
export const getPostSlugs = unstable_cache(
async () => listPostSlugs(),
['post-slugs'],
{ revalidate: 60 },
)
/**
* 同一轮渲染内的重复取数去重:React cache()。
* 它不是跨请求缓存,也不会把路由变成 SSR。
*/
export const getPostOnce = cache(async (slug: string) => getPost(slug))
/**
* 走 HTTP 接口时的写法:把缓存策略写进 fetch 选项里。
* 需要把 CMS_BASE_URL 指向你的真实接口,这里不写死任何地址与密钥。
*/
export async function fetchPostFromApi(slug: string) {
const base = process.env.CMS_BASE_URL
if (!base) {
throw new Error('未配置 CMS_BASE_URL 环境变量')
}
const res = await fetch(`${base}/posts/${slug}`, {
// 缓存 60 秒并打上标签:既能被时间策略重建,也能被 revalidateTag 精确失效
next: { revalidate: 60, tags: [`post:${slug}`, 'posts'] },
})
if (!res.ok) return null
return res.json()
}
这里有两层缓存,别搞混:
unstable_cache/fetch的缓存:缓存的是数据,跨请求存活,可以被revalidateTag按标签精确失效。- 路由段的
revalidate:管的是页面产物(HTML 与 RSC Payload)的有效期。
两者是嵌套关系:页面产物过期触发重建,重建时去读数据缓存。如果数据缓存的有效期比页面长,重建出来的页面内容并不会变新。
4.3 SSG:首页与动态路由的 generateStaticParams
// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Next.js 渲染形态演示站',
description: '同一个内容站,分别用 SSG、ISR、SSR 实现。',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<nav>
<Link href="/">首页(SSG)</Link>
<Link href="/posts">文章列表(ISR)</Link>
<Link href="/admin">后台(SSR)</Link>
</nav>
<main>{children}</main>
</body>
</html>
)
}
// app/page.tsx —— 不取任何数据,构建期整体预渲染成静态 HTML
export default function HomePage() {
return (
<article>
<h1>Next.js App Router 渲染形态演示</h1>
<p>首页没有任何数据依赖,构建期直接生成静态 HTML。</p>
<ul>
<li>/posts —— 列表页,ISR,60 秒重建一次</li>
<li>/posts/[slug] —— 详情页,SSG + ISR,未列出的 slug 首次访问时按需生成</li>
<li>/admin —— 后台,SSR,每次请求现取</li>
</ul>
</article>
)
}
动态路由想变成 SSG,关键在 generateStaticParams——它返回参数数组,框架据此在构建期把每一条路径都预渲染出来:
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getCachedPost, getPostOnce, getPostSlugs } from '@/lib/data'
// 构建期把已知 slug 全部预渲染出来,这是 SSG 的第一步
export async function generateStaticParams() {
const slugs = await getPostSlugs()
return slugs.map((slug) => ({ slug }))
}
// 未列出的 slug 允许在首次访问时按需生成,而不是直接 404
export const dynamicParams = true
// 整页每 60 秒重建一次,这是 ISR 的开关
export const revalidate = 60
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
// Next.js 15 起 params 是 Promise,必须 await
const { slug } = await params
const post = await getCachedPost(slug)
if (!post) notFound()
// 同一轮渲染内去重:多处调用只查一次,但它不走跨请求缓存,不会让路由变成 SSR
const fresh = await getPostOnce(slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.summary}</p>
<div>{post.body}</div>
<footer>
缓存版本更新于 {post.updatedAt};本轮渲染读到 {fresh.updatedAt}
</footer>
</article>
)
}
// app/posts/[slug]/not-found.tsx
export default function NotFound() {
return (
<section>
<h1>文章不存在</h1>
<p>这个 slug 没有对应内容。</p>
</section>
)
}
关于 generateStaticParams 有三条必须记住的规则:
- 必须返回数组,哪怕是空数组
[]。返回空数组表示「构建期不预渲染任何路径,全部在运行时首次访问时生成」;如果干脆不返回这个函数,路由会被判定为动态渲染。 dynamicParams默认为true。也就是说你只列出了 10 个 slug,第 11 个 slug 第一次被访问时会被正常生成并缓存,而不是直接 404。把它设成false,未列出的路径才会一律 404。- ISR 重建时不会再调用
generateStaticParams。它只在构建期跑一次,所以「哪些路径预渲染」这个决定在部署后就固定了。
4.4 ISR:列表页与 stale-while-revalidate
// app/posts/page.tsx
import Link from 'next/link'
import { getCachedPostList } from '@/lib/data'
// 整页的默认再验证周期:60 秒
export const revalidate = 60
export default async function PostsPage() {
const posts = await getCachedPostList()
return (
<section>
<h1>文章列表(ISR)</h1>
<p>内容来自带缓存的取数层,页面每 60 秒重建一次。</p>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
<p>{post.summary}</p>
<small>更新于 {post.updatedAt}</small>
</li>
))}
</ul>
</section>
)
}
这条路由的完整生命周期是这样的:
- 构建期生成一份静态 HTML 并缓存;
- 请求到达且缓存未过期 → 直接返回这份 HTML,极快;
- 缓存过期 → 先把旧内容返回给用户,同时后台异步触发重建,不阻塞当前请求;
- 重建成功后,新内容替换缓存,下一次请求拿到的就是新的。
注意第 3 步:过期瞬间用户看到的是旧内容,不是空白页,也不是转圈等重建。
4.5 按需重新验证:让业务事件决定何时刷新
固定周期有个明显缺陷:内容可能在周期开始后 1 秒就被改掉,用户要等满 60 秒才看到更新。按需重新验证把「什么时候更新」的决策权交给业务事件。
// app/admin/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { savePost } from '@/lib/cms'
export async function publishPost(formData: FormData) {
const slug = String(formData.get('slug') ?? '')
const title = String(formData.get('title') ?? '')
if (!slug || !title) {
throw new Error('slug 和 title 都不能为空')
}
// 先写数据
await savePost(slug, title)
// 再让缓存失效:路径级 + 数据标签级
revalidatePath(`/posts/${slug}`)
revalidateTag('posts', 'max')
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'
// 演示:外部系统(CMS 的 webhook)通过 Route Handler 触发按需重新验证
export async function GET(request: NextRequest) {
// 生产环境必须校验调用方身份,这里用环境变量做一道最小防护
const expected = process.env.REVALIDATE_TOKEN
const provided = request.nextUrl.searchParams.get('token')
if (!expected || provided !== expected) {
return Response.json({ revalidated: false, message: 'token 无效' }, { status: 401 })
}
const tag = request.nextUrl.searchParams.get('tag')
if (!tag) {
return Response.json({ revalidated: false, message: '缺少 tag 参数' }, { status: 400 })
}
// 两参数形式是当前推荐写法;单参数形式已废弃
revalidateTag(tag, 'max')
return Response.json({ revalidated: true, tag })
}
版本差异要写清楚:revalidateTag(tag, profile) 的两参数形式是当前推荐写法,第二个参数决定「过期内容最多还能被serve多久」:
profile="max":推荐值。内容被标记为过期,下一次请求先拿旧内容,后台重新验证。{ expire: 0 }:旧内容不再被 serve,下一次请求阻塞等待重新验证完成。外部 webhook 场景常用。- 单参数形式
revalidateTag(tag)已废弃,行为等价于{ expire: 0 }。老代码里到处都是这个形式,迁移时顺手改掉。
revalidatePath 与 revalidateTag 的分工也不同,别混用:
| 函数 | 失效范围 | 典型用法 |
|---|---|---|
revalidatePath('/posts/x') |
一条具体路径的页面产物 | 刚编辑完的那一篇文章 |
revalidatePath('/posts/[slug]', 'page') |
所有匹配该路由模式的页面 | 整个栏目重建 |
revalidateTag('posts', 'max') |
所有带 posts 标签的数据 |
列表页依赖同一份数据 |
一个容易忽略的细节:revalidatePath 只让指定路径变新。如果另一个页面(标签页)用的是同一份数据,它不会跟着更新,除非对应的标签也被 revalidateTag 失效掉。这两个函数经常要一起用。
还有一条:revalidatePath 传的必须是路由文件结构里的目标路径,不是浏览器地址栏里的路径。配了 rewrites 时尤其容易搞错——/blog 重写到 /news 的话,要传 /news。
4.6 SSR:请求时现取
// app/admin/page.tsx
import { headers } from 'next/headers'
import { getPost } from '@/lib/cms'
import { publishPost } from './actions'
// 强制每次请求现取(SSR)
export const dynamic = 'force-dynamic'
export default async function AdminPage() {
// 请求期 API:读取请求头,证明这条路由确实按请求渲染
const requestHeaders = await headers()
const userAgent = requestHeaders.get('user-agent') ?? 'unknown'
// 直接查数据源,不经过任何缓存层
const post = await getPost('app-router-rendering')
return (
<section>
<h1>后台(SSR)</h1>
<p>这条路由每次请求都现取,当前请求的 user-agent:{userAgent}</p>
<p>CMS 里的当前标题:{post?.title}</p>
<form action={publishPost}>
<input type="hidden" name="slug" value="app-router-rendering" />
<input type="text" name="title" defaultValue={post?.title ?? ''} />
<button type="submit">保存并刷新前台</button>
</form>
</section>
)
}
注意这里没有用数据层的 getCachedPost,而是直接调用 getPost。这是故意的:后台要看到最新数据,任何缓存都会在这里制造误解——你改了标题,页面却显示旧值,排查方向会完全跑偏。
export const dynamic = 'force-dynamic' 的等价语义是:把这一段里所有 fetch 的默认缓存改成 no-store,并把 fetchCache 设成 force-no-store。它是一个「兜底开关」,不是日常写法——正常情况下,你只要不在路由里碰请求期 API,框架自然就会把它判成静态或 ISR。
4.7 流式渲染:先返回骨架,再补内容
慢数据不必让整个页面干等。用 loading.tsx 或显式 <Suspense> 把慢的部分圈起来,框架会先把骨架返回给用户,数据到位后再替换。
// app/posts/loading.tsx —— 这个骨架会被预渲染成静态外壳
export default function Loading() {
return (
<section aria-busy="true">
<h1>加载中…</h1>
<p>文章列表正在生成。</p>
</section>
)
}
// app/posts/[slug]/page.tsx —— 更细粒度:只把慢的那块圈起来
import { Suspense } from 'react'
import { getCachedPost, getPostOnce, getPostSlugs } from '@/lib/data'
export async function generateStaticParams() {
const slugs = await getPostSlugs()
return slugs.map((slug) => ({ slug }))
}
export const dynamicParams = true
export const revalidate = 60
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<article>
<Suspense fallback={<p>正文加载中…</p>}>
<ArticleBody slug={slug} />
</Suspense>
</article>
)
}
async function ArticleBody({ slug }: { slug: string }) {
const post = await getCachedPost(slug)
if (!post) return <p>文章不存在</p>
const fresh = await getPostOnce(slug)
return (
<>
<h1>{post.title}</h1>
<p>{post.summary}</p>
<div>{post.body}</div>
<footer>
缓存版本更新于 {post.updatedAt};本轮渲染读到 {fresh.updatedAt}
</footer>
</>
)
}
流式渲染的两条边界,踩过的人都懂:
- 流式响应以 200 状态码开始。 一旦开始流式发送,响应头就固定了,后面再调
notFound()也只能得到一个 200 +noindex标签,而不是真正的 404。想要 404 状态码,就得在流开始之前判断。所以「文章不存在」这类判断,尽量放在generateStaticParams或路由最外层,不要放进深层 Suspense 里。 loading.tsx不包裹同级的layout.tsx。 如果 layout 自己也要现取数据,导航会被 layout 阻塞,loading.tsx的骨架出不来。解法是把 layout 里的请求期取数挪到 page,或者在 layout 内部自己加一层<Suspense>。
五、路由段配置:页面级的默认值
路由段配置是「在 page.tsx / layout.tsx / route.ts 里直接 export 一个常量」,用来给整条路由设定默认行为。
| 配置项 | 取值 | 含义 |
|---|---|---|
dynamic |
'auto'(默认) / 'force-dynamic' / 'force-static' / 'error' |
整条路由的动态还是静态。'auto' 会尽可能多地缓存,同时允许子段按需退化为动态 |
dynamicParams |
true(默认) / false |
动态路由里,未被 generateStaticParams 列出的参数是否允许首次访问时按需生成 |
revalidate |
false(默认) / 0 / 正整数(秒) |
整条路由的默认再验证周期 |
fetchCache |
'auto'(默认) / 'only-cache' / 'force-cache' / 'only-no-store' / 'force-no-store' 等 |
覆盖本段所有 fetch 的默认缓存行为。属于高级选项,多数场景用不上 |
四条必须记住的规则:
revalidate的值必须能被静态分析。export const revalidate = 600合法,export const revalidate = 60 * 10会在构建时报错——框架需要在构建期就把这个值内联进产物里。解决办法是写成常量或者写死数字。一条路由的
revalidate取各段中的最小值。 layout 设了 3600、page 设了 60,整条路由就是 60 秒。子页面永远不会被父 layout 的长周期拖住。单个
fetch的revalidate可以更小,从而让整条路由更新得更频繁。 这是很实用的一条:路由默认 3600 秒,但页面上那个「库存数量」只有 30 秒有效,就给这一个fetch写next: { revalidate: 30 },整条路由的再验证周期会跟着降到 30 秒。开发模式下页面总是按请求现算、不走缓存。 所以「改完数据没变化」这种问题只可能在生产模式复现。验证 ISR 行为必须用
next build+next start,不能用next dev。
关于 dynamic 的四个取值,有一个特别值得注意:
'force-static' 会强制预渲染并缓存数据,同时把 cookies()、headers()、useSearchParams() 的返回值强制置空。也就是说:你想做「一个整体静态、但顶部要显示当前用户名」的页面,正确的做法不是 force-static,而是让那部分数据走客户端取数。force-static 下这些 API 会静默返回空值,不报错——这正是它危险的地方。
'error' 则相反:强制预渲染,一旦有组件用了请求期 API 或未缓存的数据就直接构建报错。它是「防止这条路由意外变成动态」的护栏,适合用在必须保持静态的营销页上。
六、非 fetch 的数据访问与轮次内去重
fetch 之外的数据源(数据库、ORM、内部服务)不会自动进入 Next.js 的缓存层。两种补法:
跨请求缓存用 unstable_cache(也就是上面 lib/data.ts 里的写法),它支持 tags 与 revalidate,语义与 fetch 的缓存选项对齐。
同一轮渲染内去重用 React 的 cache():
// lib/data.ts —— 同一轮渲染内去重
import { cache } from 'react'
import { getPost } from './cms'
// layout 和 page 都取同一篇文章时,只查一次
export const getPostOnce = cache(async (slug: string) => getPost(slug))
两者的区别要分清:
cache()的生命周期是一次渲染轮次。渲染结束就没了,不会让路由变成 SSR,也不会跨请求复用。unstable_cache的生命周期是跨请求的,会一直活到被revalidateTag失效或过期。
一个反直觉的结论:cache() 套在 unstable_cache 外面是多余的——unstable_cache 本身已经跨请求缓存了,cache() 只是把同一轮里的重复调用合并掉。真正的价值在于:当你既需要跨请求缓存、又需要同一轮里只查一次时,用 unstable_cache 就够了。
另外,unstable_cache 包装的函数不能依赖请求数据:不能在内部读 cookies() / headers(),也不能调 connection()。正确做法是在外面读出来,作为参数传进去——参数会自动成为缓存键的一部分。
// 正确:把请求期数据读在缓存作用域之外,作为参数传入
export const getCachedUser = unstable_cache(
async (userId: string) => db.user.findUnique({ where: { id: userId } }),
['user-by-id'],
{ tags: ['users'], revalidate: 60 },
)
七、Next.js 16 换了模型:Cache Components 与 use cache
Next.js 16 把缓存从「路由段配置」升级成了「显式声明」。
在 next.config.ts 里打开 cacheComponents 后:
dynamic、dynamicParams、revalidate、fetchCache这些路由段配置不再使用;- 取数默认是动态的,要缓存必须用
'use cache'指令显式声明「这段数据可以缓存」; - Partial Prerendering 成为默认模型:Next.js 先预渲染一个静态 HTML 外壳,动态内容在请求时流式补进去;
unstable_cache官方建议迁移到'use cache'。
// next.config.ts —— 启用 Cache Components
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
// lib/data.ts —— 缓存声明跟着数据走,不再跟着路由走
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheTag('posts')
cacheLife('hours') // 用内置的 hours 档位,也可以自定义
return db.post.findUnique({ where: { slug } })
}
// app/posts/[slug]/page.tsx —— Cache Components 下 generateStaticParams 必须至少返回一个参数
import { getPost } from '@/lib/data'
export async function generateStaticParams() {
const slugs = await listPostSlugs()
return slugs.map((slug) => ({ slug }))
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return <h1>{post.title}</h1>
}
两套模型的对照关系:
| 维度 | 上一模型(未启用 Cache Components) | Cache Components(Next.js 16) |
|---|---|---|
| 缓存声明位置 | 路由段配置 export const revalidate |
函数/组件级的 'use cache' 指令 |
| 动态与否 | 由 dynamic 与取数行为推断 |
默认动态,必须显式声明才缓存 |
| 预渲染模型 | 整页静态或整页动态 | 静态外壳 + 动态内容流式补齐(PPR) |
| 构建输出符号 | ○ 静态 / ● SSG / ƒ 动态 |
○ 静态 / ◐ 部分预渲染 / ƒ 动态 |
generateStaticParams |
可以返回空数组 | 必须至少返回一个参数,返回空数组会构建报错 |
| 迁移方向 | — | unstable_cache → 'use cache';revalidate → cacheLife |
use cache 有几条硬约束:
- 被缓存的函数与组件不能访问
cookies()、headers()、searchParams,而且这个限制会沿调用栈传导——你调用的辅助函数里读了这些,一样报错。要在缓存作用域外读出来,作为参数传进去。 - 参数和返回值都必须可序列化。
- 缓存键包含 Build ID,所以每次部署都会让全部缓存条目失效。跨部署需要保留的数据,得用
fetch缓存或unstable_cache。 - 默认的内存缓存在 Serverless 环境下通常不跨请求存活,因为实例是临时的。自托管或持久内存的环境才稳定复用。
给正在维护老项目的建议:如果你现在跑的是 Next.js 14/15,先别急着开 cacheComponents。把上一模型的写法吃透、把 next build 的输出和缓存行为验证清楚,再迁移。开着新开关调试缓存问题,会同时打两场仗。
八、怎么验证:别猜,去看
这一节是本文最实用的部分。渲染形态和缓存行为都是可观测的,不需要靠推理。
8.1 命令
npm install # 安装依赖
npm run dev # 开发模式:页面总是按请求现算,不走缓存
npm run build # 生产构建:看路由表里每条路由的形态
npm run start # 以生产模式启动:只有这个模式才有 ISR 缓存行为
npx next build --debug-prerender # 预渲染报错时拿到更详细的堆栈
8.2 看 next build 的路由表
构建结束后 Next.js 会打印一张路由表,每个符号代表一条路由的渲染形态。
未启用 Cache Components 时:
| 符号 | 名称 | 含义 |
|---|---|---|
○ |
Static | 整条路由在构建期被预渲染成静态 HTML |
● |
SSG | 路径来自 generateStaticParams,按路径预渲染成 HTML |
ƒ |
Dynamic | 请求时现场渲染 |
启用 Cache Components 时,● 换成 ◐(Partial Prerender,静态外壳 + 请求时流式补齐动态内容)。
下面是按官方定义推导出来的预期形态(示意,不是某次构建的逐字输出;实际路径列表随你的数据变化):
Route (app)
┌ ○ /
├ ○ /_not-found
└ ● /posts/[slug]
├ ● /posts/app-router-rendering
├ ● /posts/fetch-cache-defaults
└ ● /posts/on-demand-revalidation
对着这个表反推,就知道配置有没有生效:
/posts/[slug]是●→generateStaticParams生效了,构建期确实预渲染了路径。/admin是ƒ→dynamic = 'force-dynamic'生效了。- 你以为是 ISR 的路由却是
ƒ→ 十有八九是取数没走缓存,或者路由里碰到了请求期 API。
8.3 看响应头里的缓存命中状态
生产模式下,响应头里会带 x-nextjs-cache,它直接告诉你这次请求命中了什么:
| 取值 | 含义 |
|---|---|
HIT |
直接命中缓存,没有重新生成 |
STALE |
命中了过期缓存,同时后台正在重建 |
MISS |
缓存里没有,现场渲染 |
REVALIDATED |
由按需重新验证触发并已完成重建 |
(Invoke-WebRequest -Uri 'http://localhost:3000/posts' -UseBasicParsing).Headers['x-nextjs-cache'] # 连续请求两次:先 MISS/STALE,后 HIT
8.4 打开缓存调试日志
$env:NEXT_PRIVATE_DEBUG_CACHE = '1' # 打开缓存日志
npm run start # 重启生产服务,观察 ISR 命中与后台重建
开发模式下,next.config.ts 里的 logging.fetches 能打印出每个 fetch 的完整 URL,便于确认哪些请求带了缓存策略、哪些没带。
8.5 确认内容真的在服务端 HTML 里
Invoke-WebRequest -Uri 'http://localhost:3000/posts/app-router-rendering' -UseBasicParsing |
Select-String -Pattern 'App Router 的渲染是怎么定的' -Quiet # 正文应能在服务端 HTML 里搜到,搜不到就是空壳渲染
8.6 触发一次按需重新验证
Invoke-WebRequest -Uri ("http://localhost:3000/api/revalidate?tag=posts&token=" + $env:REVALIDATE_TOKEN) -UseBasicParsing |
Select-Object -ExpandProperty Content # 用环境变量里的令牌触发按需重新验证
返回 {"revalidated":true,"tag":"posts"} 后,再请求 /posts,观察 x-nextjs-cache 是否从 HIT 变成 REVALIDATED。
九、选型表:按页面职责定渲染方式
不要用一个渲染方式套全站。绝大多数内容站的最优解是混合。
| 页面类型 | 推荐渲染 | 理由 | 配置要点 |
|---|---|---|---|
| 文档、落地页、关于页 | SSG | 内容稳定,静态 HTML 对爬虫最友好,CDN 直接兜住流量 | 不碰任何请求期数据 |
| 文章详情、商品详情 | ISR | 内容要相对新,但不需要毫秒级实时 | revalidate + generateStaticParams + 按需重新验证 |
| 列表页、标签页 | ISR | 偶尔更新,定期重建即可 | revalidate 取一个偏长的周期 |
| 搜索结果、筛选页 | SSR | 每次查询组合都不同,缓存没有意义 | searchParams 天然是请求期数据 |
| 个人中心、后台面板 | SSR 或客户端取数 | 登录后才可见,本来就不需要被收录 | 强制动态 + 权限校验 |
| 需要个性化推荐位 | SSR 优先 | 爬虫至少能读到主体内容 | 主体静态、推荐位动态 |
| 落地页上的活动倒计时 | 客户端取数 | 服务端输出的时间戳会过期 | 'use client' 组件在浏览器里算 |
判断标准可以压缩成两句话:
- 这个页面的内容需要被搜索引擎收录吗? 需要,就服务端渲染或构建期生成;不需要,客户端取数就够用,而且更简单。
- 这个页面的内容能容忍多长时间的旧数据? 能容忍一小时,用 ISR;不能容忍任何延迟,用 SSR。
十、排错清单:问题 → 原因 → 修法
| 问题 | 原因 | 修法 |
|---|---|---|
路由表里明明写了 revalidate = 60,却是 ƒ |
取数没走缓存。Next.js 15 起 fetch 默认不缓存,路由级 revalidate 覆盖不了它 |
给 fetch 加 next: { revalidate: 60 },或改用 cache: 'force-cache' |
| 页面是 ISR,但内容一直不更新 | 数据层缓存的有效期比路由长:页面 60 秒重建,读到的却是 3600 秒前的数据 | 把数据层 revalidate 调到 ≤ 路由层 revalidate |
| 改完数据页面没变 | 在 next dev 下验证。开发模式永远现算、不走缓存,改的是数据源而非缓存 |
用 next build + next start 复现 |
revalidate = 60 * 10 构建报错 |
值必须能被静态分析 | 写成常量或字面量 |
params.id 取到 undefined |
Next.js 15 起 params 是 Promise |
const { id } = await params |
revalidateTag('posts') 报类型错误 |
单参数形式已废弃 | 改成 revalidateTag('posts', 'max') |
某条路由意外变成 ƒ |
路由里读了 cookies() / headers() / searchParams,或某个子 layout 读了请求期数据 |
把请求期取数挪到更内层,或让那部分改走客户端取数 |
配了 force-static 但请求头读不到值 |
force-static 会把 cookies() / headers() / useSearchParams() 强制置空,且不报错 |
需要按请求变化的数据改走客户端;force-static 只用于真正静态的段 |
revalidatePath('/blog') 没效果 |
传了重写前的源路径 | 传路由文件结构里的目标路径 |
revalidatePath 之后另一个页面还是旧数据 |
revalidatePath 只作用于指定路径 |
同时用 revalidateTag 失效共享的数据标签 |
revalidateTag 之后前台还是旧的 |
标签没打上,或者打在了另一个数据源上 | 确认 fetch 的 next.tags 或 unstable_cache 的 tags 与失效时用的名字一致(区分大小写) |
| 动态路由某个 slug 直接 404 | 之前设过 dynamicParams = false |
改回 true,允许首次访问时按需生成 |
| 多实例部署下按需失效只生效了一部分 | 默认的文件系统缓存是按实例的,失效只作用于接收到调用的那个实例 | 配置共享的 incrementalCacheHandlerPath |
启用 cacheComponents 后报「空数组」错误 |
新模型下 generateStaticParams 必须至少返回一个参数 |
返回至少一个参数(或占位参数) |
| 缓存里放进了不该放的东西 | 服务端专属模块被客户端组件误引 | 在该模块顶部 import 'server-only' |
参考资料
- Next.js 缓存与再验证(上一模型) ——
fetch缓存选项、路由段配置、unstable_cache、轮次内去重 - Next.js 路由段配置 ——
dynamic/dynamicParams/revalidate/fetchCache的取值与 v16 变更记录 - Next.js generateStaticParams —— 预渲染动态路由、空数组与
dynamicParams的配合 - Next.js 增量静态再生 —— ISR 生命周期、按需重新验证、
x-nextjs-cache与缓存调试 - Next.js revalidatePath —— 路径失效的粒度、与
revalidateTag的分工 - Next.js revalidateTag —— profile 参数、废弃的单参数形式
- Next.js 服务端与客户端组件 —— 组件边界、序列化约束、
server-only - Next.js fetch API 参考 ——
cache/next.revalidate/next.tags的确切语义与冲突规则 - Next.js unstable_cache API 参考 —— 缓存键、参数、不可依赖请求数据的限制
- Next.js 构建你的应用 —— 路由表符号(
○/●/ƒ/◐)的官方定义 - Next.js cacheComponents 配置 —— 新模型的开关与约束
- Next.js use cache 指令 —— 缓存键构成、可序列化要求、构建卡住的排查
- Next.js loading.js 文件约定 —— 骨架、流式响应的状态码行为