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>
  )
}

两个都要记住的工程约束:

  1. 传给客户端组件的 props 必须可序列化。 函数、类实例、Symbol 都传不过去,需要传的是对象、数组、原始值。
  2. 别把服务端专属代码带进客户端模块图。 一旦一个文件标了 '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 有三条必须记住的规则:

  1. 必须返回数组,哪怕是空数组 []。返回空数组表示「构建期不预渲染任何路径,全部在运行时首次访问时生成」;如果干脆不返回这个函数,路由会被判定为动态渲染。
  2. dynamicParams 默认为 true。也就是说你只列出了 10 个 slug,第 11 个 slug 第一次被访问时会被正常生成并缓存,而不是直接 404。把它设成 false,未列出的路径才会一律 404。
  3. 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>
  )
}

这条路由的完整生命周期是这样的:

  1. 构建期生成一份静态 HTML 并缓存;
  2. 请求到达且缓存未过期 → 直接返回这份 HTML,极快;
  3. 缓存过期 → 先把旧内容返回给用户,同时后台异步触发重建,不阻塞当前请求;
  4. 重建成功后,新内容替换缓存,下一次请求拿到的就是新的。

注意第 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 的默认缓存行为。属于高级选项,多数场景用不上

四条必须记住的规则:

  1. revalidate 的值必须能被静态分析。 export const revalidate = 600 合法,export const revalidate = 60 * 10 会在构建时报错——框架需要在构建期就把这个值内联进产物里。解决办法是写成常量或者写死数字。

  2. 一条路由的 revalidate 取各段中的最小值。 layout 设了 3600、page 设了 60,整条路由就是 60 秒。子页面永远不会被父 layout 的长周期拖住。

  3. 单个 fetch 的 revalidate 可以更小,从而让整条路由更新得更频繁。 这是很实用的一条:路由默认 3600 秒,但页面上那个「库存数量」只有 30 秒有效,就给这一个 fetch 写 next: { revalidate: 30 },整条路由的再验证周期会跟着降到 30 秒。

  4. 开发模式下页面总是按请求现算、不走缓存。 所以「改完数据没变化」这种问题只可能在生产模式复现。验证 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' 组件在浏览器里算

判断标准可以压缩成两句话:

  1. 这个页面的内容需要被搜索引擎收录吗? 需要,就服务端渲染或构建期生成;不需要,客户端取数就够用,而且更简单。
  2. 这个页面的内容能容忍多长时间的旧数据? 能容忍一小时,用 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
  • #ssr
  • #ssg
  • #isr
  • #app-app-router

相关课程