4.8 Next.js 核心:路由与页面跳转
📌 基于 Next.js App Router · 2026-10 更新。本节的核心规则(文件夹 = 网址)多年未变;
params的取值方式在新版本有调整,见第 5 步的说明。
一、本节你会做出什么
你会在练习项目里做出「首页 → 列表页 → 详情页」三级结构,点击能跳转,地址栏会变,后退键也正常。
📷 效果位:三个页面的跳转演示,地址栏从
/到/attractions到/attractions/1
这一节是第 5.6 节「景点详情页」的全部技术基础。 学完你就能做出“点击卡片进详情页”这个所有网站都有的功能。
二、开始前你需要
三、跟着做
第 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。
出来了。 你没有在任何地方“注册”这个页面——建个文件夹放个文件就够了。
第 2 步:<Link> —— 为什么不用 <a>
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 -Agit commit -m "加上关于页、景点列表页与动态详情页"pnpm buildbuild 的输出里会列出所有路由(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 组件化开发:像搭积木一样拼页面