Next.js 16 App Router 实战:从 About、Blog 动态路由到全局 404
一个 Next.js 学习项目,怎样从默认首页逐步变成结构完整的小型内容站?关键不在于堆页面,而在于把 layout.tsx、动态路由、共享数据、页面元数据和 404 串成一条闭环。本文基于一个 Next.js 16.3.1、React 19.2.8、Tailwind CSS 4 项目做静态源码分析,整理可直接复用的实现路线。项目代码未在本次写作流程中执行,运行未验证。
项目最终结构
text
app/
├─ layout.tsx
├─ page.tsx
├─ not-found.tsx
├─ about/
│ └─ page.tsx
└─ blog/
├─ page.tsx
├─ posts.ts
└─ [slug]/
└─ page.tsx
components/ui/button.tsx
这组文件形成五类路由能力:共享布局、静态页面、文章列表、动态详情和未找到兜底。顶部导航连接 /、/about 与 /blog,所有后代页面复用同一个根布局。
用 layout.tsx 管理共享导航
根布局的职责是提供所有页面共同需要的外壳。项目在这里加载 Geist 字体、声明根 metadata、设置全局背景,并放置导航:
tsx
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<header>
<Link href="/">NEXT.JS</Link>
<nav aria-label="主导航">
<Link href="/about">About</Link>
<Link href="/blog">Blog</Link>
</nav>
</header>
{children}
</body>
</html>
);
}
共享内容放进 layout 后,About、Blog 和 404 不必重复顶部导航。页面只维护自己的主体内容,路由树也更容易理解。
About 页面:静态元数据与数据驱动 UI
About 页面直接导出静态 metadata:
tsx
export const metadata: Metadata = {
title: "关于 Next.js",
description: "了解 Next.js 的核心能力、优势和适用场景。",
};
页面中的四项核心能力和四个适用场景没有逐块复制 JSX,而是由数组映射。这种方式适合结构一致、内容不同的模块:新增一项只改数据,不改页面骨架。
判断标准很简单:页面标题和描述不依赖 URL 参数时,静态 metadata 足够;需要根据 slug 改变时,再使用 generateMetadata。
Blog 列表:让共享数据成为单一事实源
项目把文章字段集中在 posts.ts:
ts
export type BlogPost = {
slug: string;
index: string;
category: string;
title: string;
description: string;
publishedAt: string;
sourceUrl: string;
topics: string[];
takeaways: string[];
};
当前数据中有两篇文章。Blog 列表遍历 blogPosts,每张卡片链接到动态地址:
tsx
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.description}</p>
</Link>
))}
共享数据的价值在于,列表、详情、构建参数和元数据都依赖同一份 slug、标题与摘要,避免多处硬编码产生漂移。
动态路由:从 slug 到文章详情
app/blog/[slug]/page.tsx 捕获文章路径。这个项目使用 Next.js 16.3.1,页面 props 中的 params 是 Promise:
tsx
type BlogPostPageProps = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) {
notFound();
}
return <article>{post.title}</article>;
}
完整链路是:URL 中的动态段进入 params,页面 await params 取得 slug,再通过 getBlogPost 的 Array.find 查找文章。框架 API 会随版本变化,因此应优先查看项目安装版本的文档和类型。
generateStaticParams:提前声明已知文章
文章数据在构建时已知,因此可以从同一个数组生成动态参数:
tsx
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
这段代码把"有哪些详情页"与文章数据绑定。新增第三篇文章时,只要 slug 进入 blogPosts,参数生成逻辑无需增加分支。
注意:返回对象的键必须与 [slug] 的名称一致。如果目录叫 [id],对象也应返回 { id: ... }。
generateMetadata:为每篇文章生成标题与摘要
动态详情不能共享一个固定标题。项目在 metadata 阶段再次使用 slug 查询文章:
tsx
export async function generateMetadata({
params,
}: BlogPostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) return { title: "文章未找到" };
return {
title: post.title,
description: post.description,
};
}
页面渲染和 metadata 使用相同查询函数,能减少标题与正文不一致的风险。
notFound 与根级 404
找不到文章时,详情页调用 notFound(),而不是返回 null 或临时错误文本。根级 app/not-found.tsx 负责统一展示大号 404、说明文字和返回入口。
这形成明确的失败分支:
text
/blog/slug
→ getBlogPost(slug)
→ 命中:渲染详情
→ 未命中:notFound()
→ app/not-found.tsx
404 不是最后补上的装饰页面,而是动态内容模型的一部分。
shadcn/ui Button 如何保持链接语义
项目的 Button 基于 @base-ui/react/button,使用 class-variance-authority 管理 default、outline 等变体。最新版生成组件使用 render 把 Button 渲染为链接:
tsx
<Button
variant="outline"
render={<Link href="/blog" />}
>
返回 Blog
</Button>
外链则传入 <a>,继续保留:
tsx
<Button
render={
<a
href={post.sourceUrl}
target="_blank"
rel="noopener noreferrer"
/>
}
>
阅读原文
</Button>
这样既复用按钮样式,又不写出 <button><a> 这种交互元素嵌套。
App Router 内容站实现清单
- 根
layout.tsx只放共享字体、导航与全局外壳。 - 静态页面使用
page.tsx和静态metadata。 - 动态目录名与数据键统一,例如
[slug]对应post.slug。 - 列表、详情、静态参数和动态元数据读取同一数据源。
- 当前版本若把
params定义为 Promise,页面和元数据函数都要await。 - 未知资源调用
notFound(),并提供根级not-found.tsx。 - 站内导航使用
Link,外链保留安全属性。 - 链接式按钮保持链接语义,不嵌套交互元素。
- 新增文章后检查列表 URL、数据 slug 与动态参数键是否完全一致。
- 按项目安装版本核对 Next.js API,避免照搬旧教程。
总结
这个项目最值得复用的不是某个页面样式,而是围绕 blogPosts 建立的闭环:列表负责发现内容,动态段负责定位内容,静态参数负责声明内容,动态元数据负责描述内容,notFound() 负责处理缺失内容。
掌握这条链路后,把静态数组替换成文件系统、CMS 或数据库时,页面边界仍然可以保持稳定。下一步可以为合法 slug 和未知 slug 增加测试,再把同步查询替换为异步数据访问。当前项目未提供测试框架,本次运行未验证。
标签:
- Next.js
- React
- App Router
- TypeScript
- shadcn/ui