关于 nextjs-static-shells
nextjs-static-shells 是面向 Next.js 16 开发者的模式指南,介绍以静态外壳为主、将请求相关逻辑隔离为动态部分的架构。内容涵盖缓存、Suspense、RSC 边界与客户端交互的组织方式。
主要功能
- 01静态外壳与动态插槽架构
- 02使用 'use cache' 管理缓存
- 03通过 Suspense 流式呈现动态内容
- 04缓存标签与生命周期管理
- 05组织客户端 Provider 岛
适用场景
- 01构建静态内容与个性化控件并存的页面
- 02隔离请求相关逻辑,避免整页动态化
- 03迁移旧缓存配置与 unstable_cache
- 04检查 RSC 边界和 Suspense 用法
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ joelhooks/ joelclaw --skill nextjs-static-shells安装后在 Claude Code 中输入/nextjs-static-shells确认已加载。
附带文件agents/openai.yamlassets/small-logo.svg
详细介绍
静态优先的 Next.js 16 模式
先构建一个静态外壳,再在需要个性化或请求特定行为的地方开辟小型动态区域。
- 静态外壳 = 确定性、可缓存、首次绘制速度快
- 动态区域 = 通过 Suspense 流式传输的隔离请求/用户行为
- 客户端交互性 = Provider 岛,而非全局客户端代码蔓延
路由架构:入口 + 静态内容 + 插槽
模式
- 入口组件(服务器端,感知请求)
- 读取 params/search/auth/session/cookies
- 验证访问权限、解析 ID、准备动态 props
- 静态渲染器(服务器端,
'use cache')- 渲染确定性的布局/内容
- 接受作为插槽 props(
ReactNode)传入的动态 UI
- 动态插槽
- 从入口组件注入
- 在静态外壳中渲染时使用 Suspense 包裹
这样做的原因
- 静态外壳保持可缓存
- 动态行为显式且范围明确
- 流式传输让 UI 保持响应
- 不会意外导致整条路由动态回退
import { Suspense, type ReactNode } from 'react';
type PageProps = { params: Promise<{ slug: string }> };
/** Request-aware server entry. */
export default async function PageEntry({ params }: PageProps) {
const { slug } = await params;
const staticData = await getStaticData(slug); // deterministic
const userData = await getUserData(); // request-dependent
const dynamicPanel = <PersonalizedPanel userData={userData} />;
return <PageStatic data={staticData} panel={dynamicPanel} />;
}
type PageStaticProps = {
data: StaticData;
panel?: ReactNode;
};
/** Cached static shell. Keep request-volatile reads out. */
async function PageStatic({ data, panel }: PageStaticProps) {
'use cache';
return (
<main>
<Hero data={data.hero} />
<Content data={data.content} />
<Suspense fallback={<PanelSkeleton />}>{panel}</Suspense>
</main>
);
}
缓存组件的设置与机制
启用
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
此功能取代旧的 experimental.ppr 标志。
三种内容类型
| 类型 | 特征 | 示例 |
|---|---|---|
| 静态 | 同步、纯计算 | <header><h1>我们的博客</h1></header> |
缓存('use cache') | 异步,但对于给定输入具有确定性 | 使用 cacheLife('hours') 的 db.posts.findMany() |
| 动态(Suspense) | 运行时/请求特定,必须保持最新 | cookies()、用户会话、通知 |
'use cache' 作用域级别
// File level — entire module cached
'use cache'
export default async function Page() { /* ... */ }
// Component level
export async function CachedComponent() {
'use cache'
const data = await fetchData()
return <div>{data}</div>
}
// Function level
export async function getData() {
'use cache'
return db.query('SELECT * FROM posts')
}
使用 cacheLife() 配置缓存配置文件
import { cacheLife } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours') // Built-in: 'default' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max'
return fetch('/api/data')
}
// Or inline config:
async function getDataCustom() {
'use cache'
cacheLife({
stale: 3600, // 1h — serve stale while revalidating
revalidate: 7200, // 2h — background revalidation interval
expire: 86400, // 1d — hard expiration
})
return fetch('/api/data')
}
内置配置文件快捷方式:仅使用 'use cache' → 5m 过期 / 15m 重新验证。'use cache: remote' → 平台 KV。'use cache: private' → 允许使用运行时 API(合规性应急方案)。
缓存失效
import { cacheTag } from 'next/cache'
async function getProduct(id: string) {
'use cache'
cacheTag('products', `product-${id}`)
return db.products.findUnique({ where: { id } })
}
updateTag() — 立即在同一请求中使缓存失效:
'use server'
import { updateTag } from 'next/cache'
export async function updateProduct(id: string, data: FormData) {
await db.products.update({ where: { id }, data })
updateTag(`product-${id}`) // caller sees fresh data
}
revalidateTag() — 后台采用过期后重新验证:
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(data: FormData) {
await db.posts.create({ data })
revalidateTag('posts') // next request sees fresh data
}
缓存键生成(自动)
缓存键由以下内容生成:构建 ID + 函数位置哈希 + 可序列化参数 + 闭包变量。无需像 unstable_cache 那样手动指定 keyParts。
async function Component({ userId }: { userId: string }) {
const getData = async (filter: string) => {
'use cache'
// cache key = userId (closure) + filter (argument)
return fetch(`/api/users/${userId}?filter=${filter}`)
}
return getData('active')
}
'use cache' 内不能包含的内容
硬性规则: 缓存边界内不得包含任何逐请求变化的内容。
'use cache' 内禁止的内容 | 原因 |
|---|---|
cookies()、headers() | 特定于请求 |
searchParams | 特定于请求 |
| 会话/身份验证读取 | 特定于用户 |
| 辅助函数调用中隐藏的用户逻辑 | 隐式请求依赖 |
| 与请求生命周期相关的副作用 | 非确定性 |
Math.random()、Date.now() | 在缓存中构建时执行一次 |
修复方法:移到外部,再作为参数传入
// Wrong — runtime API inside 'use cache'
async function CachedProfile() {
'use cache'
const session = (await cookies()).get('session')?.value // Error!
return <div>{session}</div>
}
// Correct — extract in entry, pass as prop
async function ProfilePage() {
const session = (await cookies()).get('session')?.value
return <CachedProfile sessionId={session} />
}
async function CachedProfile({ sessionId }: { sessionId: string }) {
'use cache'
// sessionId becomes part of cache key automatically
const data = await fetchUserData(sessionId)
return <div>{data.name}</div>
}
例外:在无法实际重构的合规场景中,'use cache: private' 允许使用 cookies() / headers()。
RSC 边界规则
这些规则与静态外壳模式直接相关。
异步客户端组件无效
客户端组件不能是异步的。只有服务器组件可以是异步的。
// Bad
'use client'
export default async function UserProfile() {
const user = await getUser() // Cannot await in client
return <div>{user.name}</div>
}
// Good — fetch in server entry, pass data down
// page.tsx (server)
export default async function Page() {
const user = await getUser()
return <UserProfile user={user} />
}
// UserProfile.tsx (client)
'use client'
export function UserProfile({ user }: { user: User }) {
return <div>{user.name}</div>
}
不可序列化的 props 会破坏边界
从服务器端传到客户端的 props 必须可序列化为 JSON。
| 不能传递的内容 | 修复方法 |
|---|---|
| 函数(Server Actions 除外) | 在客户端组件内部定义 |
Date 对象 | 在服务器端调用 .toISOString() |
Map、Set | 使用 Object.fromEntries() / Array.from() |
| 类实例 | 传递普通对象 |
Server Actions('use server')可以传递给客户端组件——这是例外。
异步模式(Next.js 15+)
params、searchParams、cookies()、headers() 都是异步的。将其类型声明为 Promise<...>,并在入口组件中使用 await。
type PageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<{ query?: string }>
}
export default async function Page({ params, searchParams }: PageProps) {
const { slug } = await params
const { query } = await searchParams
// ...
}
对于需要使用 params 的同步客户端组件,请使用 React.use():
import { use } from 'react'
export default function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
}
Suspense 边界要求
useSearchParams 始终需要 Suspense
如果没有 Suspense,整个页面都会变为 CSR:
// Bad — entire page CSR bailout
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchBar() {
const searchParams = useSearchParams()
return <div>Query: {searchParams.get('q')}</div>
}
// Good — isolated in Suspense
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<SearchSkeleton />}>
<SearchBar />
</Suspense>
)
}
快速参考
| Hook | 是否需要 Suspense |
|---|---|
useSearchParams() | 始终需要 |
usePathname() | 在动态路由中需要 |
useParams() | 不需要 |
useRouter() | 不需要 |
Provider 岛(以简洁方式处理客户端 Provider)
规则
将客户端 Provider 挂载在尽可能低的位置,并且只在需要交互性的地方挂载。
- 好:特性级 Provider 岛
- 不好:为局部特性状态挂载全局根 Provider
模式
服务端入口传递带类型的初始状态。客户端 Provider 在 'use client' 边界内解析。Hook 保留在岛内。
'use client';
import { createContext, useContext, useMemo } from 'react';
type FeatureState = { enabled: boolean };
type FeatureContextValue = { state: FeatureState };
const FeatureContext = createContext<FeatureContextValue | null>(null);
export function FeatureProvider({
children,
initialState,
}: {
children: React.ReactNode;
initialState: FeatureState;
}) {
const value = useMemo(() => ({ state: initialState }), [initialState]);
return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
}
export function useFeature() {
const ctx = useContext(FeatureContext);
if (!ctx) throw new Error('useFeature must be used within FeatureProvider');
return ctx;
}
静态外壳模型中的数据获取
决策树
| 需求 | 模式 |
|---|---|
| 在服务端组件中读取数据 | 直接获取——无需 API 层 |
| UI 中的变更操作 | Server Action('use server') |
| 外部 API / webhook / 移动客户端 | Route Handler |
| 客户端组件需要数据 | 从服务端父组件传入(首选)或使用 Route Handler |
避免瀑布式请求
// Bad — sequential
const user = await getUser();
const posts = await getPosts();
// Good — parallel
const [user, posts] = await Promise.all([getUser(), getPosts()]);
// Better — streaming with Suspense (each section independent)
<Suspense fallback={<UserSkeleton />}><UserSection /></Suspense>
<Suspense fallback={<PostsSkeleton />}><PostsSection /></Suspense>
预加载模式
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
export const preloadUser = (id: string) => {
void getUser(id); // fire-and-forget, deduped by cache()
};
链接预加载策略
规则
- 积极预加载静态/常用路由
- 对个性化/查询密集/易变的 URL 禁用预取
- 预加载外壳,将用户专属数据延迟到动态边界之后
generateStaticParams可提升常用路径的预取命中质量
import Link from 'next/link';
/** Static/common route: keep default prefetch. */
<Link href={`/docs/${slug}`}>Read next</Link>
/** Personalized or volatile route: disable speculative prefetch. */
<Link href={`/certificate/${userId}?name=${encodeURIComponent(name)}`} prefetch={false}>
View certificate
</Link>
预加载决策清单
- 路由是静态且经常访问的吗?→ 预加载
- 路由是个性化或易变的吗?→ 不要预加载
- 用户数据是否延迟到 Suspense/动态岛之后?→ 仅预加载外壳
- 是否有实测的导航改进?→ 保留预取;否则移除
决策矩阵
| 场景 | 模式 |
|---|---|
| 静态内容 + 个性化控件 | 入口(动态)+ 缓存的静态渲染器 + 插槽注入 |
| 可缓存的确定性服务端工作 | 'use cache' 边界 |
| 纯客户端交互 | 局部 'use client' Provider 岛 |
| 更快的导航 | 定向预取 + 静态参数覆盖 |
常见故障模式及修复方法
-
整个路由意外变为动态
- 原因:与请求绑定的读取(
cookies()、headers())泄漏到静态外壳中 - 修复:将这些读取移至入口组件,传递插槽 props
- 原因:与请求绑定的读取(
-
客户端水合负担过重
- 原因:为特性局部状态在根部挂载全局 Provider
- 修复:将 Provider 下移至特性/布局片段
-
预取浪费且网络请求嘈杂
- 原因:预取个性化/查询密集的链接
- 修复:对易变 URL 使用
prefetch={false}
-
动态工作阻塞静态外壳
- 原因:动态组件未通过 Suspense 接缝渲染
- 修复:在动态插槽外包裹 Suspense,并提供简短的回退内容
-
数据流归属不明确
- 原因:静态/动态逻辑混杂在同一组件中
- 修复:通过严格的 props 契约,强制拆分 Entry 和 Static 渲染器
-
useSearchParams导致整页 CSR 回退- 原因:读取搜索参数的组件缺少 Suspense 边界
- 修复:始终将使用
useSearchParams的组件包裹在 Suspense 中
-
Date/Map/class props 悄然导致客户端组件出错
- 原因:非可序列化的 props 跨越 RSC→客户端边界传递
- 修复:在服务端进行序列化(
.toISOString()、Object.fromEntries()、纯对象)
-
代码库中仍有
unstable_cache- 原因:v16 之前的缓存模式尚未迁移
- 修复:替换为
'use cache'+cacheTag()+cacheLife()——无需手动维护键数组
从先前版本迁移
| 旧配置 | 替换项 |
|---|---|
experimental.ppr | cacheComponents: true |
dynamic = 'force-dynamic' | 移除(默认行为) |
dynamic = 'force-static' | 'use cache' + cacheLife('max') |
revalidate = N | cacheLife({ revalidate: N }) |
unstable_cache() | 'use cache' 指令 |
unstable_cache → 'use cache'
// Before
const getCachedUser = unstable_cache(
async (id) => getUser(id),
['my-app-user'],
{ tags: ['users'], revalidate: 60 }
)
// After
async function getCachedUser(id: string) {
'use cache'
cacheTag('users')
cacheLife({ revalidate: 60 })
return getUser(id)
}
主要区别:无需手动设置缓存键(根据参数和闭包自动生成),通过 cacheTag() 设置标签,通过 cacheLife() 重新验证。
限制
- 不支持 Edge 运行时——需要 Node.js
- 不支持静态导出——需要服务端
- 非确定性值(
Math.random()、Date.now())在'use cache'内会在构建时执行一次
如需在缓存外生成请求时的随机值:
import { connection } from 'next/server'
async function DynamicContent() {
await connection() // defer to request time
const id = crypto.randomUUID()
return <div>{id}</div>
}
实施顺序
- 按路由识别静态与动态输入
- 将页面拆分为 Entry + 缓存的 Static 渲染器
- 将个性化部分转换为有类型的插槽
- 在插槽渲染位置周围添加 Suspense
- 将 providers 重构为客户端岛
- 为导航链接应用预取规则
- 为高流量静态路由添加静态参数
- 测量变更前后数据(TTFB、shell 绘制时间、导航延迟、预取流量)
PR 验收标准
- 静态 shell 无需等待用户专属数据即可渲染
- 缓存边界内不包含随请求变化的读取操作
- 动态 UI 仅通过显式插槽边界呈现
- 客户端 providers 限定在功能范围内,除非有充分理由将其设为全局
- 个性化/易变链接已显式禁用预取
- 导航到常见静态路由时已预加载,且速度有可测量的提升
-
useSearchParams使用方已用 Suspense 包裹 - 没有不可序列化的 props 跨越 RSC→客户端边界
- 不再使用
unstable_cache——已迁移为'use cache'指令