跳转到内容

4.8 Next.js 核心:路由与页面跳转

📌 基于 Next.js App Router · 2026-10 更新。本节的核心规则(文件夹 = 网址)多年未变;params 的取值方式在新版本有调整,见第 5 步的说明。

一、本节你会做出什么

你会在练习项目里做出「首页 → 列表页 → 详情页」三级结构,点击能跳转,地址栏会变,后退键也正常。

📷 效果位:三个页面的跳转演示,地址栏从 / 到 /attractions 到 /attractions/1

这一节是第 5.6 节「景点详情页」的全部技术基础。 学完你就能做出“点击卡片进详情页”这个所有网站都有的功能。


二、开始前你需要

  • 已完成 4.7,有 nextjs-practice 项目且能跑起来
  • 知道 page.tsx 和 layout.tsx 的分工(4.5)

三、跟着做

第 1 步:一条规则——文件夹就是网址

Next.js 的路由不需要配置。你建一个文件夹,就多了一个网址。

app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── attractions/
│ ├── page.tsx → /attractions
│ └── [id]/
│ └── page.tsx → /attractions/任意值
└── layout.tsx (不产生网址,是外壳)

两条配套规则:

规则 说明
文件夹名 = 网址的一段 文件夹叫 about,网址就是 /about
必须有 page.tsx 才算一个页面 只建文件夹不放 page.tsx,访问会 404

🔑 这意味着你看一眼 app/ 的目录结构,就知道这个网站有哪些页面。 4.11 节「五分钟说清一个陌生项目」里,这是最快的一步。

现在动手,做出第一个新页面:

终端窗口
mkdir -p app/about

新建 app/about/page.tsx:

export default function AboutPage() {
return (
<main className="p-8">
<h1 className="text-3xl font-bold">关于这个练习站</h1>
<p className="mt-4">这是我用来练习 Next.js 路由的项目。</p>
</main>
);
}

保存,浏览器访问 http://localhost:3000/about。

出来了。 你没有在任何地方“注册”这个页面——建个文件夹放个文件就够了。

4.2 学过 <a href="/about"> 是链接。在 Next.js 里,站内跳转要用 <Link>:

import Link from "next/link";
<Link href="/about">关于</Link>

区别在哪:

<a href="/about"> <Link href="/about">
跳转时 整页刷新,白屏一下,所有东西重新加载 只换内容,瞬间切换
体验 像换了个网站 像在一个 App 里
用在哪 跳到外部网站 站内所有跳转

🔑 一条判断: 跳到自己站内的页面 → <Link>;跳到别人的网站 → <a>。

用错了不报错,只是体验变差(白屏一闪)——所以这是 code review 时值得看一眼的地方。

现在动手,给 layout 加导航(放 layout 是因为“每页都要有”——4.5 那条判断)。

打开 app/layout.tsx,在 {children} 上面加:

import Link from "next/link";
// ... 在 <body> 里,{children} 之前:
<nav className="flex gap-6 p-4 border-b">
<Link href="/" className="font-bold">练习站</Link>
<Link href="/about">关于</Link>
<Link href="/attractions">景点</Link>
</nav>

保存,刷新。每个页面顶部都有了导航,点“关于”能跳过去,而且没有白屏。

💡 /attractions 现在点了会 404,因为还没建。下一步就做它。

第 3 步:列表页

终端窗口
mkdir -p app/attractions

新建 app/attractions/page.tsx:

import Link from "next/link";
const attractions = [
{ id: 1, name: "大熊猫繁育研究基地", price: 55 },
{ id: 2, name: "宽窄巷子", price: 0 },
{ id: 3, name: "武侯祠", price: 50 }
];
export default function AttractionsPage() {
return (
<main className="p-8">
<h1 className="text-3xl font-bold mb-6">景点列表</h1>
<ul className="space-y-3">
{attractions.map(item => (
<li key={item.id}>
<Link href={`/attractions/${item.id}`} className="text-blue-600 hover:underline">
{item.name}
</Link>
<span className="ml-2 text-gray-500">
{item.price === 0 ? "免费" : `¥${item.price}`}
</span>
</li>
))}
</ul>
</main>
);
}

这段代码里,4.4 和 4.6 学的东西全在:

代码 来自
[{id, name, price}, ...] 4.4(数据像一张表格)
.map(item => ...) + key 4.4 / 4.6
{item.price === 0 ? "免费" : ...} 4.4(条件)+ 4.6(JSX 里的 {})
href={`/attractions/${item.id}`} 本节新东西:拼出来的网址

💡 那个反引号 ` 包起来、里面用 ${} 插值的写法叫模板字符串,作用就是“拼字符串”。`/attractions/${item.id}` 拼出来是 /attractions/1。

访问 /attractions,三个景点出来了,但点进去还是 404——详情页还没建。

第 4 步:动态路由——一个文件应付所有详情页

问题来了:三个景点要三个详情页,难道建 attractions/1/、attractions/2/、attractions/3/ 三个文件夹?那 100 个景点怎么办?

动态路由解决这个:文件夹名用方括号包起来。

终端窗口
mkdir -p "app/attractions/[id]"

⚠️ 方括号在终端里有特殊含义,路径要加引号(2.1 提过路径带特殊字符要加引号)。

这一个文件夹,能应付所有 /attractions/任意值:

访问的网址 id 的值
/attractions/1 "1"
/attractions/2 "2"
/attractions/panda "panda"

🔑 [id] 里的 id 是你自己起的名字。叫 [slug]、[attractionId] 都行,代码里用同一个名字取值就是。你在别人项目里会看到各种叫法。

第 5 步:详情页怎么拿到那个 id

新建 app/attractions/[id]/page.tsx:

import Link from "next/link";
const attractions = [
{ id: 1, name: "大熊猫繁育研究基地", price: 55, desc: "看熊猫要早上去,它们下午睡觉。" },
{ id: 2, name: "宽窄巷子", price: 0, desc: "三条平行的老街,适合傍晚闲逛。" },
{ id: 3, name: "武侯祠", price: 50, desc: "纪念诸葛亮的祠堂,和锦里连在一起。" }
];
export default async function AttractionDetailPage({ params }) {
const { id } = await params;
const item = attractions.find(a => a.id === Number(id));
if (!item) {
return <main className="p-8">没找到这个景点。</main>;
}
return (
<main className="p-8">
<Link href="/attractions" className="text-blue-600 hover:underline">← 返回列表</Link>
<h1 className="text-3xl font-bold mt-4">{item.name}</h1>
<p className="text-gray-500 mt-2">{item.price === 0 ? "免费" : `¥${item.price}`}</p>
<p className="mt-4">{item.desc}</p>
</main>
);
}

三行关键代码:

代码 在干什么
{ params } Next.js 自动把网址里的动态部分传进来
const { id } = await params 取出 id(就是网址最后那一段)
attractions.find(a => a.id === Number(id)) 在数据里找到对应那一条

⚠️ 两个容易踩的坑:

① params 要不要 await,各版本不同。 较新的 Next.js 里 params 是异步的,必须 await(函数也要写成 async);老版本可以直接 params.id。写错会报错,报错信息会明确告诉你该怎么改 —— 或者直接让 AI 按你的版本写。

② 网址里取出来的永远是字符串。 /attractions/1 拿到的是 "1" 而不是 1。所以要 Number(id) 转一下,否则 "1" === 1 是 false,会找不到数据(4.4 讲过 === 严格比较)。这是“详情页永远显示找不到”的头号原因。

保存,点列表里的景点——进详情页了,地址栏变成了 /attractions/1,按浏览器后退键能回去。

📷 截图位 1:列表页 → 详情页的跳转,地址栏变化

第 6 步:404 和加载状态

自定义 404 页面:新建 app/not-found.tsx

import Link from "next/link";
export default function NotFound() {
return (
<main className="p-8 text-center">
<h1 className="text-3xl font-bold">这个页面不存在</h1>
<p className="mt-4 text-gray-500">链接可能拼错了,或者这个内容还没做。</p>
<Link href="/" className="text-blue-600 hover:underline mt-6 inline-block">回首页</Link>
</main>
);
}

随便访问一个不存在的网址(比如 /xyz)试试。

加载状态:新建 app/attractions/loading.tsx

export default function Loading() {
return <main className="p-8 text-gray-400">加载中…</main>;
}

数据加载慢的时候会显示它。现在数据是写死的,看不出效果——第 9 章接真实数据库时你会感谢它。

第 7 步:嵌套 layout —— 某一类页面的专属外壳

每个文件夹都可以有自己的 layout.tsx,它们会层层嵌套:

app/layout.tsx ← 所有页面都有(导航、页脚)
└── app/attractions/layout.tsx ← 只有 /attractions 开头的页面有
└── app/attractions/[id]/page.tsx

什么时候用:景点相关的页面都要有一个侧边栏、或者统一的面包屑导航。

💡 现在不用建。 知道这个能力存在就行——第 9.7 节做后台管理页时会用到(后台所有页面共用一套侧边栏)。

第 8 步:路由速查表

我想要 怎么做
一个新页面 /about 建 app/about/page.tsx
多级网址 /blog/tips 建 app/blog/tips/page.tsx
详情页 /attractions/任意id 建 app/attractions/[id]/page.tsx
站内跳转 <Link href="/about">
跳到外部网站 <a href="https://...">
拿到网址里的动态值 组件参数 { params },取 params.id
自定义 404 建 app/not-found.tsx
加载中的样子 建 loading.tsx
某一类页面的共同外壳 在那个文件夹里建 layout.tsx

第 9 步:验收

终端窗口
git add -A
git commit -m "加上关于页、景点列表页与动态详情页"
pnpm build

build 的输出里会列出所有路由(4.7 第 6 步见过)。确认这几条都在:

/
/about
/attractions
/attractions/[id]

📷 截图位 2:build 输出的路由表

🔑 这张路由表是个好东西:它是你的网站地图。4.11 节读陌生项目时,跑一次 build 看这张表,比翻目录快。


四、完成的标志是

  • 我知道“文件夹 = 网址”,建文件夹就是建页面
  • 我知道文件夹里必须有 page.tsx 才算一个页面
  • 我做出了 /about 页面
  • 我知道站内跳转用 <Link>,外部链接用 <a>,以及区别是整页刷新
  • 我把导航加在了 layout.tsx 里(因为每页都要有)
  • 我做出了列表页,用 map 渲染并拼出了跳转链接
  • 我知道 [id] 这种方括号文件夹是动态路由,一个文件应付所有详情页
  • 我知道详情页通过 params 拿到网址里的动态值
  • 我知道从网址取出的值是字符串,和数字比较要先转换
  • 我做了自定义 404 页面
  • 我知道每个文件夹都能有自己的 layout.tsx
  • pnpm build 通过,且路由表里有四条路由

五、卡住了看这里

Q:建了文件夹,访问还是 404。 三个原因:① 文件名必须是 page.tsx,写成 index.tsx 或 Page.tsx 都不行;② 文件夹名拼错了(网址区分大小写);③ 文件里没有 export default。

Q:详情页永远显示“没找到这个景点”。 第 5 步那个坑:网址里取出的 id 是字符串。"1" === 1 是 false。要么 Number(id) 转数字,要么把数据里的 id 也写成字符串。这是本节最高频的问题。

Q:报错提到 params 应该被 await / 或者 params.id 读不到值。 版本差异(第 5 步说过)。把报错原文给 AI 说「按我项目的 Next.js 版本修正 params 的用法」,它会处理。查版本:cat package.json | grep next。

Q:点 Link 跳转后页面白屏,控制台报错。 目标页面本身有问题(不是路由问题)。直接在地址栏输入那个网址访问,看是什么报错——这招能把“路由问题”和“页面问题”分开,是 4.1 分层定位思路的应用。

Q:我在 <Link> 里用了 onClick,报错了。 <Link> 本身不需要 onClick 就能跳转。如果你确实要在跳转时做别的事,那个文件需要 "use client"(4.7 第 5 步)。

Q:mkdir 建 [id] 文件夹时报错或者建出奇怪的名字。 终端里方括号有特殊含义。加引号:mkdir -p "app/attractions/[id]"。或者直接在编辑器的文件树里右键新建文件夹,更省事。

Q:动态路由和查询参数(?id=1)有什么区别? /attractions/1 是动态路由,/attractions?id=1 是查询参数。内容型页面用动态路由——网址干净、对 SEO 友好(11.6 会讲)。查询参数适合筛选、排序、分页这类“同一个页面的不同视图”。


六、本节提示词

① 规划路由结构(做新项目时的第一步,5.2 会用到):

我要做一个【项目描述】的网站,需要这些页面:
【列出你想要的页面,比如"首页、景点列表、景点详情、美食推荐、关于我们"】
请你用 Next.js App Router 的方式给我一张路由规划表:
| 网址 | 对应文件路径 | 这个页面放什么 | 是不是动态路由 |
并且告诉我:
1. 哪些内容应该放在 layout 里(每页都有的);
2. 哪些页面需要动态路由,动态部分用什么命名合适;
3. 建议的建立顺序(先做哪个页面)。
**先不要写代码**,我确认结构后再动手。

② 加一个页面(标准句式):

请在当前项目里加一个新页面。
【网址】/【你想要的路径】
【内容】这个页面显示:【具体描述】
【导航】要不要加到顶部导航里:【要 / 不要】
【数据】先用写死的假数据,不要接任何接口
要求:
1. 用 App Router 的方式(app/ 目录下建文件夹 + page.tsx);
2. 站内链接用 next/link 的 Link 组件;
3. 不要改动现有页面;
4. 不要新增依赖包;
5. 改完告诉我访问哪个网址能看到它,以及 pnpm build 后路由表里应该多出哪一条。

③ 详情页专用(5.6 会直接复用):

我要做一个详情页:从列表页点击某一项,跳转到那一项的详情。
列表数据的形状是:
【粘贴你的数据数组,或描述有哪些字段】
请实现:
1. 动态路由页面,路径 /【列表路径】/[id];
2. 从 params 拿到 id,在数据里找到对应那一条;
3. **注意网址里的 id 是字符串**,和数据里的类型做好匹配;
4. 找不到对应数据时,显示友好提示而不是报错;
5. 详情页顶部有"返回列表"的链接;
6. 列表页的每一项加上跳转链接。
按我项目里的 Next.js 版本来写 params 的取值方式。改完告诉我怎么测试。

上一节 ← 4.7 Next.js 快速上手 下一节 → 4.9 组件化开发:像搭积木一样拼页面