Next.js App Router 模式完全指南:agents 仓库 frontend-mobile-development 插件的 8 大核心模式与缓存策略
【免费下载链接】agentsMulti-harness agentic plugin marketplace for Claude Code, Codex, Cursor, OpenCode, GitHub Copilot, and Google Antigravity项目地址: https://gitcode.com/GitHub_Trending/agents24/agents
本文基于 agents 多 harness Agent 插件市场中frontend-mobile-development插件的nextjs-app-router-patternsSkill 参考文档 references/details.md,系统讲解 Next.js App Router 的 8 大生产级模式——Server Components 数据获取、Client Components、Server Actions、Parallel Routes、Intercepting Routes、Suspense 流式渲染、Route Handlers 与 Metadata/SEO,以及配套的数据缓存策略。读完本文,你将掌握从组件边界划分、异步数据流到缓存失效控制的完整 App Router 实战方案,并能理解这套知识在 Agent 插件体系中的组织方式。
文档定位:Agent Skill 中的渐进式知识披露
nextjs-app-router-patterns是frontend-mobile-development插件(版本 1.2.3,类别 development,见 plugin.json 与 .claude-plugin/marketplace.json 中的登记条目)内置的 4 个 Skill 之一,遵循 Anthropic Agent Skills 规范的渐进式披露(progressive disclosure)结构:
plugins/frontend-mobile-development/skills/nextjs-app-router-patterns/ ├── SKILL.md # 导航层:渲染模式表、文件约定、Quick Start、最佳实践 └── references/ └── details.md # 详细层:8 大模式 + 缓存策略的完整代码示例SKILL.md 明确说明了两层分工:"Detailed pattern documentation lives inreferences/details.md. Read that file when the navigation tier above is insufficient."——即 Agent 先加载轻量导航层,只有当需要深入实现时才读取本文档。这正是该文档作为"详细参考层"的定位:导航层负责"何时用",本文档负责"怎么写"。
安装该 Skill 的方式(依据 README.md 的 Quick start 章节):
# Claude Code:安装整个插件 /plugin marketplace add wshobson/agents /plugin install frontend-mobile-development # 或仅安装单个 Skill(无需克隆仓库、无需生成步骤) gh skill install wshobson/agents npx skills add wshobson/agents --skill nextjs-app-router-patterns该插件配套的 frontend-developer agent 声明覆盖 "Next.js 15 App Router with Server Components and Client Components"、"React Server Components (RSC) and streaming patterns"、"Advanced routing with parallel routes, intercepting routes, and route handlers",本文档即为该能力声明对应的知识实现。
适用前提:文档声明面向 Next.js 14+(SKILL.md frontmatter 描述),而其中所有页面组件均采用params: Promise<...>/searchParams: Promise<...>的异步参数风格,即 Next.js 15 引入的 Promise 化路由参数写法。在 14 及以下版本中使用这些示例时,params与searchParams是同步对象,需要去掉await。
核心概念:渲染模式与文件约定
在进入具体模式之前,先继承 SKILL.md 导航层的两张核心表,它们是理解后文 8 个模式的坐标系。
渲染模式选择
| 模式 | 运行位置 | 适用场景 |
|---|---|---|
| Server Components | 仅服务端 | 数据获取、重计算、密钥访问 |
| Client Components | 浏览器 | 交互、hooks、浏览器 API |
| Static | 构建时 | 很少变更的内容 |
| Dynamic | 请求时 | 个性化或实时数据 |
| Streaming | 渐进式 | 大页面、慢数据源 |
App 目录文件约定
app/ ├── layout.tsx # Shared UI wrapper ├── page.tsx # Route UI ├── loading.tsx # Loading UI (Suspense) ├── error.tsx # Error boundary ├── not-found.tsx # 404 UI ├── route.ts # API endpoint ├── template.tsx # Re-mounted layout ├── default.tsx # Parallel route fallback └── opengraph-image.tsx # OG image generation后文的模式 4 用到default.tsx与loading.tsx,模式 7 用到route.ts,与本约定表一一对应。
Pattern 1:Server Components 数据获取
原文档的第一个模式展示了电商商品列表页的完整数据流:页面组件作为 Server Component 读取查询参数,用Suspense包裹慢数据区,子组件直接await fetch取数。
// app/products/page.tsx import { Suspense } from 'react' import { ProductList, ProductListSkeleton } from '@/components/products' import { FilterSidebar } from '@/components/filters' interface SearchParams { category?: string sort?: 'price' | 'name' | 'date' page?: string } export default async function ProductsPage({ searchParams, }: { searchParams: Promise<SearchParams> }) { const params = await searchParams return ( <div className="flex gap-8"> <FilterSidebar /> <Suspense key={JSON.stringify(params)} fallback={<ProductListSkeleton />} > <ProductList category={params.category} sort={params.sort} page={Number(params.page) || 1} /> </Suspense> </div> ) } // components/products/ProductList.tsx - Server Component async function getProducts(filters: ProductFilters) { const res = await fetch( `${process.env.API_URL}/products?${new URLSearchParams(filters)}`, { next: { tags: ['products'] } } ) if (!res.ok) throw new Error('Failed to fetch products') return res.json() } export async function ProductList({ category, sort, page }: ProductFilters) { const { products, totalPages } = await getProducts({ category, sort, page }) return ( <div> <div className="grid grid-cols-3 gap-4"> {products.map((product) => ( <ProductCard key={product.id} product={product} /> ))} </div> <Pagination currentPage={page} totalPages={totalPages} /> </div> ) }(见 details.md)
三个值得注意的要点:
key={JSON.stringify(params)}技巧:给Suspense边界设置随查询参数变化的 key,使得 URL 查询串变化(切换分类、排序、页码)时边界内容整体重建并重新触发 fallback。若不设 key,客户端导航改参数时 React 会尝试复用边界内的组件树,可能跳过重新挂起/流式过程。- 数据获取就近原则:
getProducts定义在使用数据的地方,与 SKILL.md 最佳实践 "Colocate data fetching - Fetch data where it's used" 对应。 - 缓存标签预埋:
fetch的next: { tags: ['products'] }为该响应打上标签,是后文"缓存策略"一节中revalidateTag('products')精确失效的前提。
Pattern 2:Client Components 与 'use client'
交互型组件在文件顶部声明'use client',成为客户端组件子树的根:
// components/products/AddToCartButton.tsx 'use client' import { useState, useTransition } from 'react' import { addToCart } from '@/app/actions/cart' export function AddToCartButton({ productId }: { productId: string }) { const [isPending, startTransition] = useTransition() const [error, setError] = useState<string | null>(null) const handleClick = () => { setError(null) startTransition(async () => { const result = await addToCart(productId) if (result.error) { setError(result.error) } }) } return ( <div> <button onClick={handleClick} disabled={isPending} className="btn-primary" > {isPending ? 'Adding...' : 'Add to Cart'} </button> {error && <p className="text-red-500 text-sm">{error}</p>} </div> ) }(见 details.md)
该示例与 Pattern 3 的 Server Action 形成完整闭环:addToCart是app/actions/cart.ts导出的 server action,客户端组件通过startTransition包裹调用,isPending自动反映请求进行状态(按钮禁用 + 文案切换),错误则通过返回值{ error }回传并在本地 state 中渲染。这正是 Next.js 推荐的最小客户端边界——只有按钮这一叶子节点带'use client',其余部分留在服务端。注意客户端组件只能接收可序列化 props(此处仅productId: string),这也是 SKILL.md Don'ts 中 "Don't pass serializable data - Server → Client boundary limitations" 的具体体现(原文强调 Server→Client 边界的数据必须可序列化)。
Pattern 3:Server Actions
Server Actions 是 Next.js App Router 中原生的服务端变更(mutation)机制,用"use server"指令标记模块或函数:
// app/actions/cart.ts "use server"; import { revalidateTag } from "next/cache"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; export async function addToCart(productId: string) { const cookieStore = await cookies(); const sessionId = cookieStore.get("session")?.value; if (!sessionId) { redirect("/login"); } try { await db.cart.upsert({ where: { sessionId_productId: { sessionId, productId } }, update: { quantity: { increment: 1 } }, create: { sessionId, productId, quantity: 1 }, }); revalidateTag("cart"); return { success: true }; } catch (error) { return { error: "Failed to add item to cart" }; } } export async function checkout(formData: FormData) { const address = formData.get("address") as string; const payment = formData.get("payment") as string; // Validate if (!address || !payment) { return { error: "Missing required fields" }; } // Process order const order = await processOrder({ address, payment }); // Redirect to confirmation redirect(`/orders/${order.id}/confirmation`); }(见 details.md)
该示例覆盖了 Server Actions 的三类关键能力:
- 认证检查与服务端重定向:
await cookies()(Promise 风格,Next.js 15)读取会话;未登录时redirect("/login")在服务端直接抛出重定向,不产生一次客户端往返。 - 幂等写入:
upsert以联合唯一键sessionId_productId定位购物车行,重复点击"加入购物车"时递增数量而非报错。 - 变更后缓存失效:
revalidateTag("cart")在数据写入后立即使打了cart标签的缓存失效,保证随后渲染的页面读到新数据——这与 Pattern 1 中fetch的tags配置构成端到端的缓存闭环。
checkout则演示了 Server Action 作为<form action={checkout}>处理器的形态:直接接收FormData,校验失败返回结构化错误对象,成功则redirect到订单确认页(303 模式,避免重复提交)。
Pattern 4:Parallel Routes
并行路由(Parallel Routes)允许同一布局中的多个插槽独立加载、独立展示各自的 loading 状态,插槽用@name目录表示:
// app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, team, }: { children: React.ReactNode analytics: React.ReactNode team: React.ReactNode }) { return ( <div className="dashboard-grid"> <main>{children}</main> <aside className="analytics-panel">{analytics}</aside> <aside className="team-panel">{team}</aside> </div> ) } // app/dashboard/@analytics/page.tsx export default async function AnalyticsSlot() { const stats = await getAnalytics() return <AnalyticsChart data={stats} /> } // app/dashboard/@analytics/loading.tsx export default function AnalyticsLoading() { return <ChartSkeleton /> } // app/dashboard/@team/page.tsx export default async function TeamSlot() { const members = await getTeamMembers() return <TeamList members={members} /> }(见 details.md)
目录到布局 prop 的映射规则是:app/dashboard/@analytics/下的页面组件渲染结果注入 layout 的analyticsprop,@team/注入teamprop,主路由仍是children。每个插槽可以拥有自己的loading.tsx(对应文件约定表中的 "Parallel route fallback" 条目),当该插槽数据未就绪时只挂起该面板,而不阻塞主内容。这是仪表盘类布局的标准解法:慢的 analytics 面板不会拖累主区域渲染。
Pattern 5:Intercepting Routes(模态框模式)
拦截路由(Intercepting Routes)让同一段 UI 既能作为模态框在当前页上叠出,也能作为独立全页存在,实现"同一数据、两种呈现":
// File structure for photo modal // app/ // ├── @modal/ // │ ├── (.)photos/[id]/page.tsx # Intercept // │ └── default.tsx // ├── photos/ // │ └── [id]/page.tsx # Full page // └── layout.tsx // app/@modal/(.)photos/[id]/page.tsx import { Modal } from '@/components/Modal' import { PhotoDetail } from '@/components/PhotoDetail' export default async function PhotoModal({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const photo = await getPhoto(id) return ( <Modal> <PhotoDetail photo={photo} /> </Modal> ) } // app/photos/[id]/page.tsx - Full page version export default async function PhotoPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const photo = await getPhoto(id) return ( <div className="photo-page"> <PhotoDetail photo={photo} /> <RelatedPhotos photoId={id} /> </div> ) } // app/layout.tsx export default function RootLayout({ children, modal, }: { children: React.ReactNode modal: React.ReactNode }) { return ( <html> <body> {children} {modal} </body> </html> ) }(见 details.md)
从文件结构看关键机制:
(.)语法是拦截路由的核心:app/@modal/(.)photos/[id]/page.tsx中的(.)表示"拦截相对当前层级的路由"——当用户在/photos/123页内导航(如点击列表中的另一张照片)时,匹配到的渲染进入@modal插槽以模态框形式叠出,而不离开当前页面;当从外部直接访问/photos/123时,则正常落入全页版本。default.tsx是@modal插槽的空态回退:没有路由被拦截时插槽渲染default.tsx(通常为空组件),保证布局中{modal}位置不报错。- 两种形态共享
PhotoDetail组件,模态框版本精简为纯详情,全页版本额外渲染RelatedPhotos,实现了 UI 复用且互不污染。
Pattern 6:Suspense 流式渲染
流式(Streaming)让页面"先出快的,再流慢的",把不同延迟的数据源放进各自独立的Suspense边界:
// app/product/[id]/page.tsx import { Suspense } from 'react' export default async function ProductPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params // This data loads first (blocking) const product = await getProduct(id) return ( <div> {/* Immediate render */} <ProductHeader product={product} /> {/* Stream in reviews */} <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> </Suspense> {/* Stream in recommendations */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={id} /> </Suspense> </div> ) } // These components fetch their own data async function Reviews({ productId }: { productId: string }) { const reviews = await getReviews(productId) // Slow API return <ReviewList reviews={reviews} /> } async function Recommendations({ productId }: { productId: string }) { const products = await getRecommendations(productId) // ML-based, slow return <ProductCarousel products={products} /> }(见 details.md)
结构上的分工非常清晰:
- 阻塞数据(
getProduct)在组件顶层await,决定页面的"首帧"——头部信息必须齐备才能渲染主体; - 非关键慢数据(评论 API、基于 ML 的推荐)下沉到
Reviews、Recommendations内部自行 fetch,并各自包在独立边界中。哪个先返回哪个先替换 skeleton,互不等待。
这与 Pattern 1 的差别在于:Pattern 1 是单个边界包裹整块数据(配合key做参数级重建),Pattern 6 是多个边界按数据源延迟分层(配合渐进式水合)。两者可组合使用。
Pattern 7:Route Handlers(API 路由)
需要纯 JSON 接口(第三方调用、webhook、非 React 消费端)时,用app/api/**/route.ts导出 HTTP 方法处理器:
// app/api/products/route.ts import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const category = searchParams.get("category"); const products = await db.product.findMany({ where: category ? { category } : undefined, take: 20, }); return NextResponse.json(products); } export async function POST(request: NextRequest) { const body = await request.json(); const product = await db.product.create({ data: body, }); return NextResponse.json(product, { status: 201 }); } // app/api/products/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; const product = await db.product.findUnique({ where: { id } }); if (!product) { return NextResponse.json({ error: "Product not found" }, { status: 404 }); } return NextResponse.json(product); }(见 details.md)
要点:集合路由处理GET(带可选category查询过滤、take: 20分页上限)与POST(201 返回创建结果);动态段路由处理单资源GET,未找到时返回规范化的 404 JSON 而非抛错。注意动态段处理器的第二参数同样是params: Promise<{ id: string }>,需await解包,与页面组件保持一致的 15 风格。
Pattern 8:Metadata 与 SEO
动态页面的 SEO 元数据由generateMetadata异步生成,配合generateStaticParams预渲染与notFound()兜底:
// app/products/[slug]/page.tsx import { Metadata } from 'next' import { notFound } from 'next/navigation' type Props = { params: Promise<{ slug: string }> } export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params const product = await getProduct(slug) if (!product) return {} return { title: product.name, description: product.description, openGraph: { title: product.name, description: product.description, images: [{ url: product.image, width: 1200, height: 630 }], }, twitter: { card: 'summary_large_image', title: product.name, description: product.description, images: [product.image], }, } } export async function generateStaticParams() { const products = await db.product.findMany({ select: { slug: true } }) return products.map((p) => ({ slug: p.slug })) } export default async function ProductPage({ params }: Props) { const { slug } = await params const product = await getProduct(slug) if (!product) notFound() return <ProductDetail product={product} /> }(见 details.md)
三个细节:
generateMetadata与页面组件共享同一数据源(都是getProduct(slug)),保证<title>/OG 卡与页面正文一致;查不到商品时 metadata 返回空对象、页面组件调用notFound()触发not-found.tsx(对应文件约定表)。generateStaticParams构建时枚举全部 slug,使这批商品页进入 SSG/ISR 轨道;未枚举的 slug 在运行时按需动态渲染(配合 ISR 缓存,见下节)。- OG 图片显式声明
1200×630,符合社交卡片分享的标准比例;文件约定表中的opengraph-image.tsx则是其文件式替代方案(程序化生成 OG 图)。
缓存策略:从 no-store 到标签化失效
文档最后一节(details.md)把 App Router 的缓存手段汇总为五种组合:
// No cache (always fresh) fetch(url, { cache: "no-store" }); // Cache forever (static) fetch(url, { cache: "force-cache" }); // ISR - revalidate after 60 seconds fetch(url, { next: { revalidate: 60 } }); // Tag-based invalidation fetch(url, { next: { tags: ["products"] } }); // Invalidate via Server Action ("use server"); import { revalidateTag, revalidatePath } from "next/cache"; export async function updateProduct(id: string, data: ProductData) { await db.product.update({ where: { id }, data }); revalidateTag("products"); revalidatePath("/products"); }按"新鲜度—成本"权衡理解这五档:
| 配置 | 行为 | 典型场景 |
|---|---|---|
cache: "no-store" | 每次请求回源,不缓存 | 会话数据、高频变化接口 |
cache: "force-cache" | 永久缓存(直至部署) | 静态资源、不变内容 |
next: { revalidate: 60 } | ISR:60 秒后请求触发异步再生 | 商品列表、内容页 |
next: { tags: ["products"] } | 打标签,等待显式失效 | 被多处引用、需要精确控制失效时机的数据 |
revalidateTag/revalidatePath | 在 Server Action 中主动失效 | 写操作后立即让读路径可见新数据 |
最后一个片段把前文所有模式串成闭环:updateProduct作为"use server"action 写库后,同时执行revalidateTag("products")(按 Pattern 1 中fetch打的标签失效数据缓存)和revalidatePath("/products")(按路径失效页面 HTML 缓存)。写操作与缓存失效集中在同一个 Server Action 中,避免了"数据已更新但页面仍展示旧缓存"的一致性窗口。
选择策略的经验路径:先用tags精确圈定"哪些数据会变",再在对应的 Server Action(如 Pattern 3 的addToCart中的revalidateTag("cart"))里失效;对整页级的粗粒度失效用revalidatePath;对只读且可容忍短暂滞后的读路径叠加revalidate秒数作为兜底。
最佳实践清单(继承自导航层)
SKILL.md 的 Do's/Don'ts 是对 8 个模式的收敛总结,逐条对应上文示例:
Do's
- 从 Server Components 起步——确需交互时才加
'use client'(Pattern 2 中仅按钮是客户端组件) - 数据获取就近放置——在消费它的地方 fetch(Pattern 1、6 均如此)
- 使用 Suspense 边界——为慢数据启用流式(Pattern 1、6)
- 利用 Parallel Routes——让各面板有独立 loading 状态(Pattern 4)
- 使用 Server Actions——带渐进增强能力的变更操作(Pattern 3)
Don'ts
- 不要跨边界传不可序列化数据——Server → Client 只允许可序列化 props(Pattern 2 仅传
productId字符串) - 不要在 Server Components 中使用 hooks——无
useState/useEffect - 不要在 Client Components 中 fetch——用 Server Components 或 React Query 承担数据获取
- 不要过度嵌套 layouts——每层 layout 都增加组件树深度
- 不要忽略 loading 状态——始终提供
loading.tsx或Suspensefallback(Pattern 4 的@analytics/loading.tsx即示例)
相关资源索引
| 资源 | 路径 |
|---|---|
| 本文核心参考文档(8 模式 + 缓存策略) | plugins/frontend-mobile-development/skills/nextjs-app-router-patterns/references/details.md |
| Skill 导航层(渲染模式表、文件约定、Quick Start、Do/Don'ts) | plugins/frontend-mobile-development/skills/nextjs-app-router-patterns/SKILL.md |
| 配套前端 Agent 定义 | plugins/frontend-mobile-development/agents/frontend-developer.md |
| 插件元数据 | plugins/frontend-mobile-development/.claude-plugin/plugin.json |
| 市场目录登记 | .claude-plugin/marketplace.json |
| 插件市场安装与多 harness 支持说明 | README.md |
以上模式代码均可直接复制到 Next.js 15 项目(TypeScript)中使用;示例中的db假定为 Prisma 或 Drizzle 等数据库客户端,process.env.API_URL等环境变量需按实际部署配置。若你在 Claude Code、Codex、Cursor、OpenCode、Antigravity 或 Copilot 中使用 agents 插件市场,安装frontend-mobile-development插件后即可让 Agent 在这些模式上按本文路径直接查阅参考文档。
【免费下载链接】agentsMulti-harness agentic plugin marketplace for Claude Code, Codex, Cursor, OpenCode, GitHub Copilot, and Google Antigravity项目地址: https://gitcode.com/GitHub_Trending/agents24/agents
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考