Next.js 增量静态再生(ISR)完整指南
理解 Next.js ISR 的工作流程、动态路由、按需重新验证与部署实践。
增量静态再生(Incremental Static Regeneration,ISR)允许 Next.js 在应用运行期间创建或更新静态页面,而不必重新构建整个网站。
它保留了静态页面响应快、服务器开销低的优势,同时允许内容按照时间或业务事件更新。因此,ISR 适合商品详情、博客文章、营销页面和文档等公开内容。
本文以 App Router 为例,介绍 ISR 的工作流程、动态路由、按需重新验证、适用场景与部署注意事项。
本文示例使用未开启 cacheComponents 的 App Router 缓存模型。如果项目在 Next.js 16 中启用了 Cache Components,应改用 'use cache'、cacheLife 和 cacheTag 描述缓存行为。
为什么需要 ISR ?
传统静态网站会在构建阶段生成所有页面。生成后的 HTML 可以直接从缓存或 CDN 返回,访问速度很快,但页面数量越多,构建所需的时间通常也越长。
假设一个电商网站拥有 100,000 个商品页面,每个页面平均需要 50ms 才能生成。仅生成这些页面就需要大约 83 分钟,而且每次修改价格或描述后重新构建整个网站,会重复大量没有必要的工作。
动态渲染可以在每次请求时读取最新数据,但也会增加数据库、接口和服务器的压力。当内容不需要精确到每一次请求都保持最新时,ISR 提供了更合适的折中方案:
- 在构建阶段只预渲染访问量较高的页面,缩短构建时间。
- 首次访问其他页面时按需生成,并缓存生成结果。
- 页面过期后在后台重新生成,访问者仍可立即获得旧的可用版本。
- CMS 或数据库发生变化时,只让相关路径或数据失效。
ISR 如何工作?
ISR 采用 stale-while-revalidate 模式。假设一个页面的重新验证时间为 60 秒,请求会按照以下流程处理:
- Next.js 在构建阶段或首次访问时生成页面,并保存到缓存。
- 60 秒内的请求直接获得缓存页面。
- 60 秒后到达的第一个请求仍会立即获得旧页面,同时触发后台重新生成。
- 新页面生成成功后会替换旧缓存,之后的请求将看到更新内容。
- 如果重新生成失败,Next.js 会继续提供最后一次成功生成的页面,并在后续请求中重试。

需要注意的是,60 秒表示页面可以开始重新验证的最短间隔,而不是一个定时任务。没有新请求到达时,Next.js 不会仅因为时间已过就主动重新生成页面。
配置基于时间的重新验证
在 App Router 的页面文件中导出 revalidate,即可设置路由的重新验证间隔。下面的商品列表最多每小时进入一次重新验证流程:
interface Product {id: string;name: string;}export const revalidate = 3600;export default async function ProductsPage() {const response = await fetch('https://api.example.com/products');if (!response.ok) {throw new Error('Failed to fetch products');}const products: Product[] = await response.json();return (<main><h1>Products</h1><ul>{products.map((product) => (<li key={product.id}>{product.name}</li>))}</ul></main>);}
当缓存超过 3600 秒后,下一位访问者仍会获得已有页面,Next.js 则在后台生成新版本。生成成功后,后续访问者会看到最新商品列表。
重新验证间隔应根据业务能够接受的陈旧时间设置。博客列表可以使用一小时,商品库存可能只适合几分钟。如果内容必须实时准确,应考虑动态渲染,而不是将间隔缩短到一两秒。
为动态路由使用 ISR
商品详情通常使用 app/products/[id]/page.tsx 这样的动态路由。如果构建阶段生成全部商品会耗费太长时间,可以通过 generateStaticParams 只返回最常访问的商品:
import { notFound } from 'next/navigation';interface Product {id: string;name: string;description: string;}export const revalidate = 3600;export async function generateStaticParams() {const response = await fetch('https://api.example.com/products/popular');const products: Product[] = await response.json();return products.map((product) => ({id: product.id,}));}export default async function ProductPage({params,}: {params: Promise<{ id: string }>;}) {const { id } = await params;const response = await fetch(`https://api.example.com/products/${id}`);if (response.status === 404) {notFound();}if (!response.ok) {throw new Error('Failed to fetch product');}const product: Product = await response.json();return (<main><h1>{product.name}</h1><p>{product.description}</p></main>);}
这个路由会按以下方式生成页面:
- generateStaticParams 返回的热门商品会在 next build 期间预渲染。
- 未返回的商品 ID 会在首次请求时按需生成,生成结果会用于后续请求。
- 不存在的商品通过 notFound() 返回 404 页面。
- 已缓存的商品页面会按照 revalidate 设置重新验证。
dynamicParams 默认为 true,因此未在 generateStaticParams 中列出的参数仍然可以按需生成。如果商品集合固定,并且希望未知 ID 直接返回 404,可以添加以下配置:
export const dynamicParams = false;
这种方式让构建时间与最重要的页面数量相关,而不是与网站的全部页面数量相关。应优先预渲染热门、可预测或对首次访问速度要求较高的页面,其余页面交给运行时按需生成。
按需重新验证
基于时间的重新验证适合允许短暂陈旧的内容,但它无法准确知道数据何时发生变化。例如,CMS 中的文章刚刚发布,或者商品价格刚刚调整,此时可以通过按需重新验证立即让相关缓存失效。
App Router 提供两种常用方式:
- revalidatePath:使指定页面或布局路径失效。
- revalidateTag:使带有指定标签的数据失效,可同时影响多个使用这份数据的页面。
这两个函数都只能在服务器环境中使用,例如 Server Action 或 Route Handler,不能在客户端组件或 Proxy 中调用。
使用 revalidatePath 更新指定页面
当更新操作与具体路由对应时,可以在 Server Action 中调用 revalidatePath:
'use server';import { revalidatePath } from 'next/cache';export async function updateProduct(id: string, formData: FormData) {await saveProduct(id, formData);revalidatePath(`/products/${id}`);}
数据保存成功后,Next.js 会让对应商品路径失效。在 Server Action 中调用时,如果用户正在查看受影响的路径,界面会立即更新;在 Route Handler 中调用时,则会先标记该路径,并在下一次访问时重新生成内容。它不会立即批量生成所有匹配页面。
如果传入的是动态路由模式,还必须说明要重新验证页面还是布局:
revalidatePath('/products/[id]', 'page');
调用 revalidatePath('/products/[id]', 'page') 会让所有匹配该页面文件的路径在后续访问时重新验证。只更新一个商品时,应传入 /products/123 这样的具体路径,避免让无关页面失效。
使用 revalidateTag 更新共享数据
同一份商品数据可能同时出现在详情页、分类页和首页。逐个列出路径容易遗漏,此时可以为请求添加缓存标签:
const response = await fetch(`https://api.example.com/products/${id}`, {next: { tags: [`product:${id}`] },});
数据发生变化后,使用相同标签进行重新验证:
'use server';import { revalidateTag } from 'next/cache';export async function updateProduct(id: string, formData: FormData) {await saveProduct(id, formData);revalidateTag(`product:${id}`, 'max');}
revalidateTag(tag, 'max') 会将相关数据标记为陈旧。下次使用这些数据时,Next.js 先返回旧内容,再在后台获取新内容。单参数形式的 revalidateTag(tag) 已被弃用,不应继续使用。
如果 Server Action 提交成功后必须让当前用户立即看到自己的修改,可以使用 updateTag 立即使标签过期。updateTag 只支持 Server Action,适合 read-your-own-writes 场景;普通内容更新仍可使用 revalidateTag 的后台刷新行为。
通过 CMS Webhook 触发重新验证
外部 CMS 无法直接调用 Server Action,可以创建 Route Handler 接收 webhook。下面的接口会验证密钥,然后让对应文章路径失效:
import { revalidatePath } from 'next/cache';import type { NextRequest } from 'next/server';export async function POST(request: NextRequest) {const secret = process.env.REVALIDATE_SECRET;const authorization = request.headers.get('authorization');if (!secret || authorization !== `Bearer ${secret}`) {return Response.json({ message: 'Unauthorized' }, { status: 401 });}const body: { slug?: string } = await request.json();if (!body.slug) {return Response.json({ message: 'Missing slug' }, { status: 400 });}revalidatePath(`/posts/${body.slug}`);return Response.json({ revalidated: true });}
将 REVALIDATE_SECRET 保存在服务器环境变量中,并让 CMS 在内容发布后使用 Authorization 请求头调用该接口。不要把重新验证接口公开为无需认证的端点,否则任何人都可以反复使缓存失效,增加服务器负载。
如果应用配置了 rewrite,revalidatePath 必须接收实际路由文件对应的目标路径,而不是浏览器中显示的源路径。
ISR 与其他渲染方式的区别
Next.js 允许在同一个应用中组合多种渲染方式。选择方案时,应根据内容更新频率、是否依赖请求信息,以及能否接受短暂陈旧来判断。
| 方式 | 生成时机 | 适合场景 | 主要取舍 |
|---|---|---|---|
| 静态生成 | 构建阶段 | 小型官网、很少变化的文档 | 响应快,但更新需要重新构建 |
| ISR | 构建阶段或首次访问,并在运行时重新验证 | 商品页、博客、营销页面 | 响应快,但可能短暂显示旧内容 |
| 动态渲染 | 每次请求 | 个性化页面、实时库存、权限相关内容 | 数据新鲜,但服务器开销和 TTFB 更高 |
| 客户端请求 | 页面到达浏览器后 | 高交互界面、仅当前用户可见的数据 | 交互灵活,但初始 HTML 可能没有完整内容 |
适合使用 ISR 的情况
以下内容通常适合使用 ISR:
- 页面可以公开缓存,不依赖用户身份或请求 Cookie。
- 内容需要更新,但允许短时间内显示旧版本。
- 页面数量较多,全部预渲染会明显拖慢构建。
- 页面访问分布不均,可以优先生成热门路径。
- CMS 或业务系统可以通过 webhook 通知内容变化。
不适合使用 ISR 的情况
以下情况应优先考虑其他渲染方式:
- 账户、购物车等内容因用户而异,不能共享页面缓存。
- 金融报价、库存确认等数据必须在每次请求时保持最新。
- 网站规模较小,完整构建只需很短时间,传统静态生成更简单。
- 页面只能导出为纯静态文件,没有可执行 Next.js 服务器的运行环境。
错误处理
重新生成页面时,不应使用错误响应覆盖一个仍然可用的缓存版本。数据请求失败时应抛出错误,让 Next.js 保留最后一次成功生成的内容:
const response = await fetch('https://api.example.com/products');if (!response.ok) {throw new Error(`Failed to fetch products: ${response.status}`);}const products = await response.json();
后台重新验证失败后,访问者仍会获得旧页面。下一次请求到达时,Next.js 会再次尝试重新验证。这种行为优先保证了页面可用性,但也意味着应通过日志和监控及时发现持续失败的更新任务。
部署和运行限制
ISR 依赖 Next.js 服务器维护并更新缓存,因此部署时需要注意以下限制:
- ISR 只支持 Node.js Runtime,不支持 Edge Runtime。
- Node.js 服务器和 Docker 容器支持 ISR,output: 'export' 静态导出不支持。
- 多个实例默认拥有各自的文件系统缓存。应配置共享缓存处理器,使缓存内容和失效事件在实例之间同步。
- 如果一个预渲染路由包含多个不同 revalidate 时间的 fetch 请求,路由会采用其中最短的时间进行 ISR。
- 路由中的请求使用 cache: 'no-store' 或 revalidate: 0 时,该路由会改为动态渲染。
- 按需重新验证不会执行 Proxy 中的路径处理,因此必须传入真实目标路径。
Next.js 会自动为 ISR 页面设置适当的缓存响应头,不要手动用 Cache-Control 覆盖框架生成的策略。自托管在 CDN 后方时,还应保留 Next.js 设置的 Vary 响应头,避免 HTML 与 React Server Components 数据使用不同的缓存版本。
在本地验证 ISR
开发模式下的请求和缓存行为与生产环境不同。验证 ISR 时,应先构建应用,再启动生产服务器:
bun run buildbun run start
可以在 .env 中临时启用缓存调试日志:
NEXT_PRIVATE_DEBUG_CACHE=1
服务器日志会显示构建阶段生成的页面,以及运行时的缓存命中和重新生成过程。还可以查看响应中的 x-nextjs-cache 标头:
- HIT:页面直接来自缓存。
- STALE:返回了旧缓存,同时在后台重新验证。
- MISS:缓存中没有页面,本次请求进行了新渲染。
- REVALIDATED:页面通过按需重新验证完成了更新。
要点
ISR 让 Next.js 可以在构建后继续生成和更新静态页面。它通过缓存响应降低服务器压力,并使用 stale-while-revalidate 在后台更新过期内容。
使用 revalidate 可以按时间刷新路由;使用 generateStaticParams 可以只在构建阶段生成重要的动态路径;使用 revalidatePath 和 revalidateTag 可以在内容真正发生变化时精确地使缓存失效。
ISR 的重点不是让所有页面都缓存,而是为能够容忍短暂陈旧的公开内容选择合理的生成和更新策略。实时或个性化数据仍应使用动态渲染,而小型且很少变化的网站通常继续使用传统静态生成即可。