学 App Router,真正该串起来的是路由、数据和失败分支
很多 App Router 示例只告诉你:建一个目录,再放一个 page.tsx。这足以显示页面,却不足以解释一个内容站如何长期保持一致。更关键的问题是:列表里的文章从哪里来?动态详情如何查找?构建期如何知道有哪些 slug?页面标题如何跟随文章变化?未知地址最终由谁处理?
本文基于一个 Next.js 16.3.1 小项目的静态源码,拆解一条从共享布局到全局 404 的完整链路。你会得到一份可迁移的内容站结构和自检清单。项目代码未在本次写作流程中执行,运行未验证。
先看结论:不要把五个 API 分开学
这个项目包含首页、About、Blog 列表、两篇动态详情和根级 404。真正把它们连接起来的是两条主线:
text
UI 路由线:URL → layout → page → not-found
内容数据线:blogPosts → 卡片 → slug → 详情 → metadata
layout.tsx、[slug]、generateStaticParams、generateMetadata 和 notFound() 不是互相独立的知识点。它们共同回答一个问题:一份内容如何被发现、定位、描述和兜底。
路由树先表达页面关系
项目的核心目录很小:
text
app/
├─ layout.tsx
├─ not-found.tsx
├─ about/page.tsx
└─ blog/
├─ page.tsx
├─ posts.ts
└─ [slug]/page.tsx
根布局加载 Geist 字体并提供全局导航,children 是当前匹配页面。这样 About、Blog 和文章详情不必分别维护一份导航。
目录层级也直接表达 URL:about/page.tsx 是 /about,blog/page.tsx 是 /blog,blog/[slug]/page.tsx 接收任意文章 slug。目录是路由结构,layout 是共享 UI 结构,两者一起构成页面树。
单一数据源比动态路由本身更重要
项目没有数据库,而是在 posts.ts 中定义 BlogPost 和两篇文章。每篇数据同时包含:
slug:路由键;title、description:卡片和 metadata;category、publishedAt:展示信息;topics、takeaways:详情内容;sourceUrl:外部原文。
列表页只做映射:
tsx
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.description}</p>
</Link>
))}
详情查询也只有一个入口:
ts
export function getBlogPost(slug: string) {
return blogPosts.find((post) => post.slug === slug);
}
材料中的独有细节是:这份数组不仅服务列表和详情,还同时驱动静态参数与动态元数据。数据集中后,新增文章不需要维护多份路由表。
Next.js 16 中,先尊重本地类型
动态页面 props 被写成:
tsx
type BlogPostPageProps = {
params: Promise<{ slug: string }>;
};
页面先 await params,再查询文章:
tsx
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) notFound();
return <article>{post.title}</article>;
}
这提醒了一个更通用的工程判断:Next.js 的 API 和类型会随版本变化。面对旧教程与当前项目冲突时,优先级应是"安装版本文档 → 本地类型 → 示例文章",而不是凭记忆修改代码。
构建参数和页面元数据必须来自同一事实
构建期已知的文章可以通过 generateStaticParams 声明:
tsx
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
动态标题则由 generateMetadata 生成:
tsx
export async function generateMetadata({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) return { title: "文章未找到" };
return {
title: post.title,
description: post.description,
};
}
二者都不维护额外常量,而是回到 blogPosts。这让"页面是否存在"和"页面如何描述"不会脱离内容本身。
失败分支应该回到路由约定
未知 slug 被 getBlogPost 查询为 undefined 后,页面调用 notFound()。根级 app/not-found.tsx 提供统一的 404 视觉和两个恢复入口。
如果详情组件只是 return null,用户看不到原因,框架也得不到清晰的未找到语义。把失败分支交给 notFound(),等于让不存在的业务资源重新进入路由系统。
可以把它记成一条固定链路:
text
URL slug
→ await params
→ getBlogPost
→ 有数据:渲染详情
→ 无数据:notFound
→ 根级 not-found.tsx
UI 组件也要服从导航语义
项目正式初始化了 shadcn/ui。Button 基于 Base UI,CVA 管理 default、outline 等 variant,Tailwind 类名通过 cn 合并。
值得注意的是链接按钮:它没有写成按钮嵌套链接,而是使用 Base UI 的 render:
tsx
<Button
variant="outline"
render={<Link href="/blog" />}
>
返回 Blog
</Button>
外部原文同样把 <a> 作为最终元素,并保留 target="_blank" 与 rel="noopener noreferrer"。组件复用不能以牺牲 HTML 语义为代价。
一份可直接复用的内容站自检表
路由结构
- 共享 UI 位于最近的 layout,不在多个页面复制。
- 动态目录名与业务键一致,例如
[slug]对应post.slug。 - 列表中的 href 确实指向动态路由。
数据一致性
- 列表、详情、静态参数、元数据使用同一数据源。
- 新增文章只需增加一条数据,而不是修改多份路由配置。
- 查询函数对未知 slug 返回明确的空值。
框架边界
- 按当前 Next.js 版本处理
params。 - 动态内容使用
generateMetadata,静态内容直接导出 metadata。 - 未知资源调用
notFound(),根路由提供not-found.tsx。
交互语义
- 站内跳转使用 Next.js
Link。 - 外链保留安全属性。
- 按钮样式链接最终仍渲染为链接元素。
还能继续改进什么
当前首页仍保留 create-next-app 的起始内容,根 metadata 也是默认标题;如果要把项目真正当成内容站,下一步应统一站点名称和描述。
当前文章数据是同步静态数组。换成 CMS 或数据库时,可以保留 getBlogPost(slug) 这个边界,只把内部实现改为异步读取。这样列表、详情和元数据的职责不会被数据源变化打乱。
最后,应为两个分支补测试:合法 slug 能渲染文章,未知 slug 进入 404。当前项目没有测试配置,因此这里只给出验证方向,不声称已经运行通过。
结语
App Router 的学习重点不是记住多少特殊文件,而是能否画出完整的数据和路由链路。对内容站而言,一个可迁移的判断是:让同一数据源决定页面入口、页面内容和页面描述,再让框架约定处理失败分支。
现在检查你的项目:如果新增一篇文章需要同时改列表、详情、路由参数和标题配置,说明单一事实源还没有建立起来。
摘要: 本文通过一个 Next.js 16.3.1 小型内容站,说明 Root Layout、动态 slug、generateStaticParams、generateMetadata、notFound 与 shadcn/ui 链接按钮如何组成完整链路;仅基于源码静态分析,运行未验证。
标签: Next.js / React / App Router / TypeScript / shadcn-ui