Tarquin note
Tarquin note

App Router 的价值不在“新”,而在边界能不能被写清楚。
记录一次用 App Router 复刻公开站点的拆分策略:页面保留为 Server Component,交互只放在明确的 Client Component 边界内,动态路由的 params 按 Promise 处理,构建前把数据和视图的责任边界先切清楚。
如果要对照官方行为,先看 Next.js App Router 文档,再决定项目里要不要跟进新 API。
复刻一个静态站点,Pages Router 完全能胜任。但 App Router 有几个优势让最终选择偏向了它:
"use client" 的组件才被打包Suspense 边界让页面可以逐步加载,不需要等所有数据就绪params 改为 Promise,配合 async/await 使用更安全如果你的项目没有 SSR 需求、不需要 React Server Components,Pages Router 依然是最简单的选择。不要为了"用新技术"而引入不必要的复杂度。
核心原则:页面是 Server Component,只有需要交互的最小区域才是 Client Component。
app/
├── layout.tsx # Server Component(根布局)
├── page.tsx # Server Component(首页)
├── blog/
│ └── [slug]/
│ └── page.tsx # Server Component(文章详情)
└── tags/
└── page.tsx # Server Component(标签页)
components/
├── layout/
│ └── site-dock.tsx # "use client"(导航交互)
└── sections/
├── article/
│ └── article-detail.tsx # Server Component
└── tags/
└── tags-explorer.tsx # "use client"(标签筛选)
判断标准很简单:组件里用到了 useState、useEffect、事件监听器(onClick 等)——就加 "use client"。其他全部保持 Server Component。
App Router 下,数据获取直接在 Server Component 里用 async 函数:
// app/blog/[slug]/page.tsx
export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return <ArticleDetail article={post} />;
}
不需要 getServerSideProps 或 getStaticProps,函数本身就是数据获取的入口。
Next.js 15 开始,params 变成了 Promise。这意味着你必须 await 才能拿到值:
// 正确写法
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
// 错误写法(会在构建时报错)
export default function Page({ params }: { params: { slug: string } }) {
const slug = params.slug; // TypeError: params is not iterable
}
如果你从旧版本迁移,务必全局搜索 params 的类型声明,全部改为 Promise 包裹。否则构建时不会报错,但运行时会炸。
升级前先开一个最小复现页,把 params、generateStaticParams 和 dynamicParams 的行为跑通,再迁移真实页面。
静态站点离不开 generateStaticParams:
export const dynamicParams = false; // 只允许预生成的路径
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
dynamicParams = false 表示所有未在 generateStaticParams 中列出的路径都会返回 404,而不是尝试按需渲染。对纯静态博客来说这是正确的行为。
每次 bun run build 之前,确认这三件事:
"use client" 只出现在真正需要交互的组件上——用 grep -r "use client" components/ 检查params 都用了 await——用 TypeScript 类型约束保证generateStaticParams 覆盖了所有有效路径——dynamicParams = false 兜底这三步做完,构建出来的就是纯静态 HTML + 最小的客户端 JS bundle。
| 边界 | 推荐做法 | 不推荐做法 |
|---|---|---|
| 页面数据 | Server Component 读取 | |
| 小交互 | 单独 Client Component | 把整页标成 "use client" |
| 路由生成 | generateStaticParams | 依赖运行时猜路径 |
"use client"。params 类型。