学 App Router,真正该串起来的是路由、数据和失败分支

学 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]generateStaticParamsgenerateMetadatanotFound() 不是互相独立的知识点。它们共同回答一个问题:一份内容如何被发现、定位、描述和兜底。

路由树先表达页面关系

项目的核心目录很小:

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/aboutblog/page.tsx/blogblog/[slug]/page.tsx 接收任意文章 slug。目录是路由结构,layout 是共享 UI 结构,两者一起构成页面树。

单一数据源比动态路由本身更重要

项目没有数据库,而是在 posts.ts 中定义 BlogPost 和两篇文章。每篇数据同时包含:

  • slug:路由键;
  • titledescription:卡片和 metadata;
  • categorypublishedAt:展示信息;
  • topicstakeaways:详情内容;
  • 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

相关推荐
zzzzzz3105 小时前
react-bits:酷炫组件之外,React 页面表现力真正该怎么借力?
react.js·开源·动效
风月说与山鬼14 小时前
三、大括号语法
前端·react.js
Zkaisen1 天前
mtreoCVE-2025-11953学习(目录遍历与远程命令执行)
学习·react native·react.js·metro
zzzzzz3101 天前
react-bits 为什么会吸引前端开发者:别把动效当装饰,先把它当成页面能力
前端·react.js·动效
风月说与山鬼1 天前
二、React入口文件(main.jsx)
前端·react.js
张元清2 天前
React useTimeout Hook:声明式 setTimeout 与自动清理 (2026)
javascript·react.js
嘟嘟07172 天前
JWT 登录认证完整流程(React + Vite 实战复习)
react.js·vite
meilindehuzi_a2 天前
React JWT 登录鉴权实战:从无状态 HTTP 到路由守卫、Zustand 与 Axios 拦截器
前端·react.js·http
张元清3 天前
React useEventListener Hook:类型安全的 DOM 事件监听 (2026)
javascript·react.js