🟡 进阶

Next.js 从第一行代码到上线

它到底是什么、什么时候该用它、怎么在今晚跑出你的第一个页面——顺便把 16 版里最容易踩空的三处改动讲清楚

📅 更新于 2026-09-13

本文校对于 Next.js @ 16.3.5 · 官方源:nextjs.org/docs

16.3.5
当前最新版本
20.9+
要求的 Node.js 版本
15
分钟跑起第一页
142k
GitHub Stars

0030 秒速览:先拿走这三句话

🎯 读完你能做什么

你能在今晚跑出一个能被人访问的网站(首页 + 关于页 + 一个能点的链接),并且在自己写的代码里说清楚「这一行是在服务器上跑的,还是在你访客的浏览器里跑的」——后者是 Next.js 最容易搞错、也最值钱的一点。

如果只记三句话

  1. 文件夹就是网址:在 app/about/ 里放一个 page.tsx,你就自动多了一个 /about 页面,不用填任何路由表。
  2. 默认在服务器上跑,要交互才切成浏览器:组件默认在服务端拼好 HTML 再发出去,只有点击、输入、动画这些必须在浏览器里发生的事,才在文件顶部加一行 "use client"。
  3. 先 build 再上线:npm run dev 能跑不等于能上线,npm run build 过了才是真的过了。

一张表:你现在的处境 → 该看哪几节

你现在的状态先看这几节为什么是这几节
完全没碰过 React,只想先看看 Next.js 长什么样00 → 01 → 02 → 03 → 04先建立「它是什么、为什么值得学」的判断,再用最小例子尝到甜头,不碰组件分界也能跑
会写一点 React,想做一个能发出去的完整网站03 → 04 → 06 → 07 → 09 → 10你的卡点在「数据从哪来、怎么跳转、怎么上线」,这些正是 06–10 的主题
已经在用 Next.js,但报错 params 相关的东西看不懂07-2、07-3、1316 版把同步取参数彻底删掉了,这是旧教程最集中的过时点
在维护一个老项目,代码里写的是 middleware.ts11-4、13官方已把它改名成 proxy,搞混会出现「改了文件没生效」

关键数字先给量级

16.3.5本文校对的 Next.js 版本
20.9.0最低 Node.js 要求(Node 18 已不支持)
4记住 4 个文件名,就能覆盖 90% 的路由需求
3进阶三件套:Server Actions / Route Handlers / proxy

上手路径:五步,今晚能走完

1
查环境

跑 node -v,要 ≥ 20.9.0。低于这个数,先去 nodejs.org 装 LTS 版。

2
建项目

npx create-next-app@latest my-app,第一个问题选「用推荐配置」,其余问题只回答一个「项目叫什么名字」。

3
跑起来

cd my-app && npm run dev,浏览器开 http://localhost:3000。

4
亲手加一个页面

建 app/about/page.tsx,访问 /about 看它出现——这一步做完你就理解「文件即路由」了。

5
上线

npm run build 无报错,把代码推到 GitHub,在 Vercel 里导入仓库,两分钟后你有一个真实网址。

⚠️ 三句话,先记住

① 参数是异步的。16 版里 params 是一个需要 await 的东西(一个「承诺之后会给你值」的对象),写 params.slug 会直接报错。旧教程几乎全是你现在会撞车的那种写法。

② middleware.ts 已经改名。16 版起这个文件叫 proxy.ts,导出函数也叫 proxy,而且默认跑在完整的 Node.js 环境里而不是轻量边缘环境。老代码可以继续跑但会警告。

③ 取数据默认不缓存了。早年「fetch 默认帮你缓存」的说法已经失效,现在是「取数据要缓存,得你自己说一声」。

新手最容易提前踩的三个坑

把它当纯后端学
它首先是 React 框架。先会一点点组件和 JSX(React 里那种写在 JavaScript 里的、长得像 HTML 的标签语法),再学它事半功倍。
两套路由混着抄
官方同时维护 app/ 和 pages/ 两套写法。新项目只用 app/;pages/ 只在接手老项目时查。混抄是新手第一大乱源。
服务端 / 客户端组件分不清
这是 app/ 路由最核心的新概念。分不清就会遇到「useState 不允许用」这类报错——第 06 节专门拆它。

01Next.js 到底是什么(先破四个错想)

它不是一门新语言,也不是 React 的替代品

这是最容易搞错的第一处。React 是一个画界面的库:你告诉它「这块内容长什么样」,它负责把画面渲染出来。它只管画面,不管你网站的地址怎么分、数据从哪来、接口写在哪、上线之后跑在哪。

Next.js 是建在 React 之上的一整套约定加工具。所谓「约定」,意思是它不给你一堆开关去配,而是规定「你把文件放在这个名字下、用这个名字命名,我就知道你想干什么」。你少配很多东西,代价是要先记住这套命名规则。

📖 官方原话

Next.js 的官方仓库描述只有一句:“The React Framework”——「给 React 用的框架」。这个自称很准确:它是来给 React 补齐短板的,不是来取代 React 的。

四个最常见的错想,逐个破掉

你可能会以为实际上是为什么重要 / 什么时候会撞上
「它是一个新语言/新语法」写的是标准 JavaScript 或 TypeScript,加上 React 的写法。没有新语法要学如果你在犹豫「要不要先学一门新语言再回来」,不需要。会 JS 就能开始抄
「它是 React 的竞品,选它就不用 React 了」它内部就是 React,你写的每个页面仍然是 React 组件决定「要不要学 React」时别绕开——React 的基础(组件、props、JSX、useState)仍然要用到
「它是一个部署平台/云服务」它是一个可以下载、在自己电脑上跑的软件包。官方那个部署平台叫 Vercel,是另一件事看到账单或「要付费」的说法时别慌:框架本身免费开源,你可以部署到任何能跑 Node 的服务器
「它只能做网站前台」前台、后台接口、表单处理、定时更新,一个项目里都能写判断「要不要再单独起一个后端项目」时用得上:小项目通常不需要

它和你已经见过的「网站」差在哪

用你每天都在经历的体验来说明。传统的纯前端 React 项目,你打开网址时会先看到一片空白,然后页面的字才一个一个冒出来——因为浏览器得先下载一大包 JavaScript,再自己把页面拼出来。如果你的网慢,这个空白会持续一两秒。

Next.js 默认换了个做法:它先在服务器上把页面拼成完整的 HTML 文字,再把这份拼好的结果发给你。浏览器拿到就能直接画出来,所以你看到的是内容,不是空白。拼好之后,它再悄悄把 JavaScript 送下来,让按钮、输入框这些能点能动——这一步的行业叫法是「注水」(hydration),意思是给已经画好的静态骨架注入生命力。

💡 为什么这件事值得单独讲

「先拼好再发」带来三个直接好处:打开快、搜索引擎能直接读到你的内容、密钥不用发到浏览器。反过来,它也是 Next.js 大部分「新手坑」的根源——因为代码现在可能在两个地方跑:服务器上,或者访客的浏览器里。第 06 节整节都在讲这条分界线。

它内部有哪几块拼图

拼图它管什么为什么重要 / 何时用 · 别当什么用
App Router新的路由体系:app/ 目录下的文件夹结构决定网址新项目默认用它;别和老的 pages/ 写法混着抄
Turbopack打包与开发服务器(把源码整理成浏览器能跑的形式,并做热更新)16 版起是默认引擎,不再需要传额外参数;别把它当成另一个框架,它只是引擎
React 服务端组件组件默认在服务端渲染、可以不发 JavaScript 到浏览器理解「为什么首屏快」的关键;别在它里面写点击事件
Server Actions写在服务端的函数,表单可以直接调用它留言、点赞这类写操作省掉一个接口文件;别拿它做大文件上传
Image / Font / Metadata图片优化、字体优化、页面标题与描述开箱就有的三项优化,每个页面顺手用;别等上线才补

版本这件事,你要知道的方法

本文写定时的最新稳定版是 16.3.5(2026-09-11 发布)。Next.js 大约每几个月出一个大版本,而你手里的 AI 助手、教程、搜索结果里混着好几个版本的写法——这是新手最容易被带偏的地方。

所以给你一条比「记住版本号」更有用的做法:在项目里问版本,而不是在脑子里记版本。

# 看你的项目装的是哪个版本
npm ls next

# 看当前最新稳定版是几
npm view next version
📖 16 版这个坎为什么特殊

官方在升级指南开头写了一句很直白的话(大意):「这不是你认识的那个 Next.js,这个版本有破坏性改动,API(你能调用的那些函数和写法)、约定和文件结构都可能和你训练数据里的不一样。」——这句话是写给 AI 助手看的,也是写给你的:如果你拿一份 2024 年的教程对着 16 版敲,撞车是必然的,不是你笨。

02它替你做了什么:六个问题一次答完

用纯 React 做网站,你会接连撞上六个问题。Next.js 的价值就是这六个问题它都有现成答案。

问题一:地址栏里的 /about,怎么对应到一个页面?

纯 React 项目里,地址和页面的对应关系得你自己搭(要装一个专门的库,写一份路由表,再手动维护它和文件的同步)。Next.js 的做法是:你把文件放对位置,网址就自动有了。

app/about/page.tsx   →   网址 /about
app/blog/page.tsx    →   网址 /blog
app/page.tsx         →   网址 /(首页)

这就是「文件即路由」。行业的通俗说法也叫「约定优于配置」——用一套命名规矩,换掉一大堆要你手填的配置文件。

顺便先埋一个你会反复遇到的词:middleware(直译「中间件」)。它指的是「请求进入页面之前先替你看一眼的那道关卡」,用来做登录拦截、跳转这类事。本文第 10 节会专门讲它——因为 16 版刚把它改了名字,那是本文要提醒你的第二个「旧教程会把你带偏」的地方。

问题二:搜索引擎抓不到我的内容怎么办?

搜索引擎派来的自动程序(行业叫爬虫)读的是服务器返回的原始 HTML。纯前端渲染的页面,返回的原始 HTML 几乎是空的,内容是浏览器后来用 JavaScript 拼上去的——爬虫未必等得及。Next.js 默认就在服务器把内容放进 HTML 里,爬虫一读就读到。

问题三:接口写在哪?

你不需要另起一个后端项目。在 app/api/hello/route.ts 里导出一个函数,/api/hello 就是一个能返回 JSON 数据的接口(JSON 是一种人和机器都好读的文本数据格式)。

问题四:图片太大、加载慢?

用官方的 Image 组件替代原生的 <img> 标签,它自动压缩、按需加载、并预留位置防止页面跳动。

问题五:上线要折腾多久?

把代码推到 GitHub,在 Vercel 上导入这个仓库,点一次部署。之后每次推送代码,网站自动更新。

问题六:AI 助手总给我写旧版 API 怎么办?

16 版的脚手架会随项目生成一份 AGENTS.md(给 AI 助手看的说明书),里面写明「请以本版本官方文档为准」。你也可以手动把它补到旧项目里:

npx @next/codemod@canary agents-md

六个问题,一张表收口

你本来要自己解决的问题Next.js 给的答案为什么重要 / 何时用 · 别当什么用
地址与页面的对应文件夹即路由(app/about/page.tsx)省掉一个路由库和一份要同步的路由表;别指望它能替你做权限判断
首屏空白、SEO 差默认服务端渲染,先出 HTML对外页面、内容页的首选;内部强交互后台可以另说
接口无处安放Route Handlers(app/api/*/route.ts)给外部或跨端用的 JSON 接口;站内表单优先用 Server Actions,少写一个文件
图片与字体的体积Image / Font 组件开箱即用,几乎零成本;别为了「自己可控」而绕开它们
上线流程推到 GitHub → Vercel 自动部署新手上线最快的一条路;公司内网等场景仍可自己 build 后部署
AI 写出过时代码脚手架自带 AGENTS.md 指向版本匹配的文档提问时把官方文档链接一起贴给它;别完全依赖记忆里的写法
⚠️ 诚实的另一面

Next.js 的便利全部建立在约定上:文件名、目录名都有含义。享受便利的前提是先记住这套约定。写错一个文件名的字母,页面就是 404——这不是 bug,是约定的代价。第 05 节把四个关键文件名一次讲清。

03什么场景该用它,什么场景别用

一句话判断法

问自己:我要做的是「一个能在网上打开的完整网站」,还是「一段练习代码」?前者用 Next.js,后者别用。它给你的每一样好处(路由、渲染、部署)都只在「要发布出去」时才兑现;你只在自己电脑上练语法时,这些全是白背的负担。

该用它的五种场景

📝
博客 / 文档站
内容为主、要能被搜索到、不常改。静态生成加按需更新几乎是为此而生。
🛒
电商 / 商品页
首屏速度和搜索排名直接影响成交,同时又要购物车这类交互。
📊
后台管理界面
需要登录拦截、表单写库、页面间跳转,一个项目里全能写完。
🚀
全栈小产品
你一个人做,不想同时维护前后端两个仓库、两套部署。
🌐
公司官网 / 落地页
要快、要能被搜到、改版频繁;页面不多但要求稳。
🧪
想学全栈但不知从哪起
它把前端、后端接口、部署串成一条最短路径,适合当第一个完整项目。

别用它的三种场景

你的情况更合适的选择为什么不要硬上 Next.js
只想练 JavaScript / React 语法,做单页小 demoVite + ReactNext.js 的服务端那一半对你完全用不上,反而多一层「这个文件到底在哪跑」的困惑
纯展示页面,完全不碰 NodeHugo / Hexo 这类静态生成器你要的只是「把 Markdown 变成网页」,用一个需要 Node 环境的框架是杀鸡用牛刀
已有庞大后端,只需要套一个管理后台壳子任何前端框架都行Next.js 最大的优势(前后端一体)对你的项目不成立,学它的成本收不回来

和别的方案比:新手选型表

下面三条不是「谁更好」,是「谁的代价你更愿意付」。

方案适合什么你要付的代价
纯 React(Vite)练手 demo、给现有页面内嵌一小块组件路由、搜索引擎优化、接口全部自己搭
Next.js完整网站、博客、后台、全栈小产品要接受它的文件命名约定,写错名字就没页面
Vue + Nuxt喜欢 Vue 语法的同类需求中文 AI 生态与招聘岗位规模略小于 React 系
静态生成器纯文档、纯展示、完全无交互一旦需要交互就得换方案重做
💡 不确定的时候怎么选

选 Next.js。理由是它的试错成本最低:往上能长成全栈应用,往下只当静态站发布也不需要改架构;而反过来,从静态生成器迁移到有交互的站点,通常要推倒重来。

「适合谁 / 不适合谁」——放在这里而不是开头

画像适不适合原因
完全不会 JavaScript建议先补先去把变量、函数、数组、对象四样弄熟,再来会顺很多。这不是能力问题,是顺序问题
会一点 JS,没碰过 React可以边做边补本文的例子都完整可抄。同时并行补 React 的组件、props、JSX、useState 四样就够
会 React,没做过上线非常适合你的瓶颈恰好是 Next.js 最强的那部分:渲染、路由、部署
只想要一个静态博客,讨厌折腾考虑更轻的方案先诚实评估:你愿不愿意为「以后可能加的交互」付现在的学习成本

04从零装到跑起来

这一节的目标很具体:十五分钟内,你的浏览器里出现一个属于你的页面,改一个字它自动变。全程只有四条命令。

第一步:确认你的 Node 版本够不够

Node.js 是让 JavaScript 能脱离浏览器、在你电脑上直接运行的程序(行业叫「运行时」)。Next.js 依赖它。打开终端(Mac 上按 Command + 空格,输入「终端」回车),敲:

node -v
# 要看到 v20.9.0 或更高。推荐 v20 或 v22 的 LTS(长期支持)版
npm -v
# 能输出版本号就说明 npm(Node 自带的包管理器)可用
⚠️ 这里是旧教程最容易过时的一处

很多教程写「Node 18 以上就行」。16 版起 Node 18 已经不支持了,最低要求是 20.9.0(官方升级指南原文:“Minimum version now 20.9.0 (LTS); Node.js 18 no longer supported”)。用旧版本会在一堆看不懂的地方报错。

还没装 Node?三步装好

1
下载 LTS 安装包

打开 nodejs.org,下载标着 LTS 的那个版本,一路「下一步」装完。

2
关掉终端重开

再跑一次 node -v,能看到版本号就是装好了。重开这一步不能省,环境变量要重新加载。

3
(可选但推荐)用版本管理器

如果你以后会有多个项目要求不同的 Node 版本,用 fnm 或 nvm 管起来,避免互相打架。

第二步:一条命令建项目

npx create-next-app@latest my-app
# my-app 是文件夹名,换成你想要的名字,比如 my-blog
# npx 的意思是「临时下载这个工具跑一次,不用先装到全局」

它会问你几个问题。16 版的流程被简化过:第一个问题选「用推荐配置」,剩下的问题就只剩「项目叫什么名字」。

它会问什么建议怎么选为什么这么选 / 选错的后果
Would you like to use the recommended Next.js defaults?
(要不要用推荐默认配置)
Yes(直接回车)一步拿到 TypeScript + ESLint + Tailwind + App Router + AGENTS.md 全套。选了 No 会掉进七连问,对新手没必要
Would you like to use TypeScript?Yes类型检查能提前发现拼写和结构错误,AI 也写得更准。不想学类型也能先按 JS 写,它不会拦你
Which linter would you like to use?ESLint注意 16 版这里是一个问题三选一:linter 就是「自动帮你挑毛病的代码检查器」。ESLint 是用了很多年的老牌,遇到问题最好搜到答案;Biome 是后起之秀,把检查和格式化合成一个工具;None 就是不装。新手选 ESLint
Would you like to use React Compiler?No(先不装)这是 16 版新增的提问,早期阶段可选。新手先少一个变量,以后随时能开
Would you like to use Tailwind CSS?Yes不用单独写 CSS 文件,直接在 className 里写样式类名。想要纯 CSS 就选 No,脚手架不会拦你
Would you like your code inside a src/ directory?No(新手不用)选了以后所有代码都套一层 src/,本教程的路径要自己加前缀。先不选,少一层心智负担
Would you like to use App Router?Yes本文全部基于 App Router。选 No 会得到旧的 pages/ 结构,和本文对不上
Would you like to customize the import alias?Yes,保持 @/*让你能用 @/components/X 代替 ../../../components/X。别改成别的
Include AGENTS.md to guide coding agents?Yes16 版默认自带,它是给 AI 助手看的版本说明书。跳过它等于让 AI 凭记忆写代码

第三步:启动,并确认真的成功了

cd my-app
npm run dev
# 终端出现 "Ready in Xms" 并且给出 http://localhost:3000

浏览器打开 http://localhost:3000,看到 Next.js 的欢迎页就对了。然后改一个字试试:打开 app/page.tsx,随便改一句文案,保存,回到浏览器——页面自己变了。这个能力叫热更新:不用手动重启服务器,改完就生效。

📖 三步都过才算装好

① 终端显示 Ready;② 浏览器能打开欢迎页;③ 改字之后页面自动变。任何一步不过,把终端里完整的红色报错复制给你的 AI 助手——不要只截一句话,也不要自己猜着改。

装出来的项目里都有什么

my-app/
├── app/                  # 页面和路由都住这里(你 90% 的时间在这)
│   ├── layout.tsx        # 全站外壳:导航栏、页脚写这里
│   ├── page.tsx          # 首页,对应网址 /
│   └── globals.css       # 全局样式
├── public/               # 图片、图标等静态文件,引用时从 / 开头
├── next.config.ts        # Next.js 的配置文件
├── tsconfig.json         # TypeScript 配置(自动生成,先别动)
├── package.json          # 依赖清单和三条脚本:dev / build / start
└── AGENTS.md             # 给 AI 助手的版本说明书(16 版自带)

三个常用命令,先分清它们的场合

命令它做什么什么时候用 · 别拿它做什么
npm run dev启动开发服务器,带热更新写代码的全程都开着;别用它来判断「能不能上线」,它的检查比真实构建松很多
npm run build打包 + 全量类型检查 + 生成上线用的文件每次推送代码前跑一次;它是唯一能告诉你「能不能上线」的命令
npm start以生产模式运行已经打包好的结果本地预览「上线后的真实效果」时用;必须先 build,否则会报错

装的过程中卡住了

现象原因怎么办
下载依赖特别慢或超时默认的包仓库在境外切国内镜像:npm config set registry https://registry.npmmirror.com,然后重跑创建命令
提示权限不足用了系统自带的旧 Node不要加 sudo。先确认 Node 是官方安装包或 fnm 装的,不是系统自带的那份
想用 pnpm / yarn 而不是 npm——完全可以。加 --use-pnpm 之类的参数,或建完后用对应包管理器重装依赖;dev / build 命令不变
端口 3000 被占用(Port in use)上一次的 dev 没关掉换个端口启动:npm run dev -- -p 3001,然后开 localhost:3001

05最小例子:两个页面 + 一个能点的链接

目标:亲手验证「文件即路由」。做完你会有首页、关于页,以及它们之间不刷新的跳转。全程不用改任何配置。

第 1 步:确认项目正在跑

终端里 npm run dev 保持开着,浏览器能打开 localhost:3000。

第 2 步:把首页换成你自己的话

打开 app/page.tsx,把 return ( ... ) 里的内容换掉,保存,看浏览器自己变。这一步是在验证热更新通不通。

第 3 步:新建一个关于页

新建文件夹 app/about/,在里面新建文件 page.tsx。访问 http://localhost:3000/about——页面出现了,而你一行路由配置都没写。

第 4 步:让两个页面能互相跳

// app/page.tsx —— 首页
import Link from "next/link";

export default function Home() {
  return (
    &lt;main&gt;
      &lt;h1&gt;我的第一个 Next.js 网站&lt;/h1&gt;
      &lt;p&gt;改一行字、保存、浏览器自动变——这就是热更新。&lt;/p&gt;
      &lt;Link href="/about"&gt;去关于页看看 →&lt;/Link&gt;
    &lt;/main&gt;
  );
}
// app/about/page.tsx —— 关于页
import Link from "next/link";

export default function About() {
  return (
    &lt;main&gt;
      &lt;h1&gt;关于我&lt;/h1&gt;
      &lt;p&gt;这个文件住在 app/about/page.tsx,所以网址是 /about。&lt;/p&gt;
      &lt;p&gt;这就是「文件即路由」:文件夹名 = 网址路径。&lt;/p&gt;
      &lt;Link href="/"&gt;← 回首页&lt;/Link&gt;
    &lt;/main&gt;
  );
}

为什么用 Link 而不是 <a> 标签

Link 是 Next.js 提供的链接组件,它做的是客户端跳转:只换掉页面内容,不整页重新加载。你看到的效果是切换很快、页面不闪白,而且页面上已填了一半的表单不会被清空。

用它自己的原话说,它通过「预取」(prefetch)提前把目标页准备好——你把鼠标移到链接上时,浏览器已经在悄悄下载下一页了。

📖 什么时候还是用 <a>

跳到别的网站(比如 https://nodejs.org)时用普通的 <a>。Link 是给站内跳转用的——它管不了别人家的站点。

做完请确认这三条

检查什么应该看到什么没看到说明什么
访问 /首页显示你写的中文标题文件存的位置不对,或 dev 服务器没在跑
访问 /about关于页出现,且你没改过任何配置文件名不是 page.tsx,或大小写写错(Page.tsx 无效)
点链接跳转没有整页白屏闪一下如果你用的是 <a>,换成 Link

顺手加一点样式(可选)

脚手架带了 Tailwind:不写 CSS 文件,直接在 className 里写类名。把首页的 <main> 换成下面这样,立刻像个正经网站:

// app/page.tsx —— 只换样式,逻辑一个字没动
&lt;main className="max-w-2xl mx-auto p-8"&gt;
  &lt;h1 className="text-3xl font-bold mb-4"&gt;我的第一个 Next.js 网站&lt;/h1&gt;
  &lt;p className="text-gray-400 mb-6"&gt;改一行字、保存、浏览器自动变——这就是热更新。&lt;/p&gt;
  &lt;Link href="/about" className="text-indigo-400 underline"&gt;
    去关于页看看 →
  &lt;/Link&gt;
&lt;/main&gt;
📖 这三个类名各管什么

max-w-2xl mx-auto = 限制最宽、左右居中;p-8 = 四边留白;text-3xl font-bold = 大标题、加粗。不用背——用到哪个现查,查三次就记住了。查不到就打开 Tailwind 官网的搜索框,输入你想做的效果。

06四个文件名,决定一个页面能不能被访问

这一节是全文最该记住的一节。Next.js 的「魔法」全靠这四个名字,写错一个字母,页面就是 404。

四个特殊文件名分别是干什么的

文件名用房子打个比方,它是什么每个路由都必需吗 · 缺了会怎样
page.tsx房间本身——用户真正看到的内容必需。没有它,这个网址就是 404,哪怕文件夹和别的文件都在
layout.tsx楼房的框架——走廊、电梯、门牌,包住里面所有房间不必需,但根目录那个必需。它对同级及以下所有页面生效,且跳转时不重新渲染
loading.tsx等电梯时墙上的广告屏——数据还没到,先给点东西看不必需。有了它,进这个分区时先看到占位内容,数据到了自动替换
error.tsx房间里的灭火器——出错了兜住,别让整站崩不必需。有了它,这个分区出错时显示你写的提示,而不是一片报错

为什么 page.tsx 这个名字不能省

因为 Next.js 用「有没有 page.tsx」来判断「这个文件夹算不算一个可访问的网址」。你可以有 app/blog/ 文件夹,里面放着组件、工具函数、样式;只要没有 page.tsx,/blog 就打不开。这不是限制,是给「只想放文件、不想开页面」留的空间。

⚠️ 高频误判

「我明明建了 app/blog.tsx,为什么 /blog 是 404?」——因为路由只认文件夹里的 page.tsx,不认 app/blog.tsx 这种平铺的文件。文件系统大小写敏感,Page.tsx、page.ts(换成 js)之外的名字一律不认。

layout.tsx:导航栏只写一次,全站生效

根 layout.tsx 是每个页面外面那层壳。你把导航栏写在这里,所有页面自动都有——不用每个页面复制一遍。

// app/layout.tsx —— 根外壳,所有页面都会被它包住
import Link from "next/link";
import "./globals.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="zh-CN"&gt;
      &lt;body&gt;
        &lt;nav&gt;
          &lt;Link href="/"&gt;首页&lt;/Link&gt; |{/* 导航只写这一次,全站生效 */}
          &lt;Link href="/about"&gt;关于&lt;/Link&gt; |
          &lt;Link href="/blog"&gt;博客&lt;/Link&gt;
        &lt;/nav&gt;
        &lt;hr /&gt;
        {children}{/* 每个页面的内容会被塞到这里 */}
        &lt;footer&gt;© 2026 我的小站&lt;/footer&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
💡 {children} 是什么

它是一个「占位洞」。Next.js 把当前网址对应的那个页面的内容,塞进这个洞里。这就是为什么导航栏不会随页面切换而重新出现——壳没变,只是洞里的东西换了。副作用是壳里的状态(比如搜索框里打的字)在跳转时会保留。

layout.tsx 可以嵌套:不同板块穿不同外壳

把 layout.tsx 放进子文件夹,它就只包住那个文件夹里的页面。营销页一套外壳、后台另一套外壳,互不干扰——第 08 节的路由组就是为这个场景准备的。

节点级兜底:loading.tsx 与 error.tsx

// app/blog/loading.tsx —— 进博客分区先看到它,数据到了自动替换
export default function Loading() {
  return &lt;p&gt;正在加载文章列表…请稍候&lt;/p&gt;;
}
// app/blog/error.tsx —— 这个分区出错时的兜底界面
"use client"; // error.tsx 必须是客户端组件:它要能响应点击
export default function Error({ error, reset }: { error: Error; reset: () =&gt; void }) {
  return (
    &lt;main&gt;
      &lt;h1&gt;出了一点小问题&lt;/h1&gt;
      &lt;p&gt;{error.message}&lt;/p&gt;
      &lt;button onClick={reset}&gt;再试一次&lt;/button&gt;
    &lt;/main&gt;
  );
}
⚠️ 为什么 error.tsx 必须标 "use client"

因为它里面有个「再试一次」按钮,按钮要能响应点击——而点击事件只能在浏览器里发生。这条规则在第 07 节会讲透。记住这个反直觉的点:报错页面本身也是一个交互组件。

07服务端组件和客户端组件:一条分界线,两套后果

这是 App Router 最核心、也最容易把人卡住的新概念。本节按五步拆:它不是什么 → 它是什么 → 怎么运作 → 为什么重要 → 新手坑在哪。

第一步:它不是「前端项目 vs 后端项目」那种分工

你可能会想:「服务端」和「客户端」听起来像是要建两个项目、或者两个文件夹,一个写后端一个写前端。

不是。这两个词说的不是项目、不是文件夹、也不是两种文件类型。它指的是同一份组件代码,在哪个地方被执行——是在你租的那台服务器上执行完再发结果,还是在访客的浏览器里执行。

第二步:它到底是什么——一行声明决定运行位置

在 app/ 里写的每一个组件,默认都是服务端组件:它在服务器上执行、把结果拼成 HTML 发出去,自身不往浏览器发送 JavaScript。行业缩写叫 RSC(React Server Component 的首字母)。

如果你想让某个组件变成客户端组件(在浏览器里跑、能响应点击和输入),就在那个文件的最顶部写一行:

"use client"; // 这一行写在文件最顶端,声明「我在浏览器里跑」

import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return &lt;button onClick={() =&gt; setN(n + 1)}&gt;点了 {n} 次&lt;/button&gt;;
}

第三步:怎么运作——一句话版本

服务端组件在服务器执行,可以直接读数据库、用密钥调外部接口,这些代码和密钥都不会下发到浏览器;渲染出来的结果(HTML)发给浏览器。

客户端组件会被打包进 JavaScript 一并下发,浏览器拿到后接手,恢复点击、输入、动画这些交互能力——这一步行业叫「注水」(hydration),意思是给已经画好的静态页面注入生命力。

第四步:为什么这个默认值重要

默认服务端带来什么具体效果什么时候你会明显感受到
首屏更快浏览器先拿到完整 HTML,不用等 JavaScript 下载完才看到内容手机弱网下打开你的站时,差别最直观
密钥不泄露只有客户端组件的代码会下发;服务端代码留在服务器上接数据库、接第三方付费接口时,这就是安全底线
下发体积更小不需要交互的组件不产生 JavaScript页面越多越明显;纯展示型页面几乎只有 HTML
能直接读数据库组件函数可以是 async,直接 await 查询结果不必再写「请求接口 → 等 JSON」那一套

第五步:新手坑在哪(对照看,一眼就懂)

❌ 服务端组件里写交互
// 没加 "use client",却用了 useState → 运行时报错
import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return &lt;button onClick={() =&gt; setN(n + 1)}&gt;{n}&lt;/button&gt;;
}
✅ 拆出一个客户端组件
"use client"; // 顶行声明:我在浏览器里跑
import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return &lt;button onClick={() =&gt; setN(n + 1)}&gt;{n}&lt;/button&gt;;
}
⚠️ 看到这个报错就去文件顶部加一行

在服务端组件里用 useState、onClick、useEffect 这类东西,会报 useState is not allowed in Server Components 之类的错。修法不是把它改成别的东西,而是在那个文件第一行加 "use client"。

该把 "use client" 加在哪一层

这是最容易加错的地方。惯例是加到最末端的叶子组件上,不要加到整页。

// ✅ 推荐:页面本身是服务端组件,只有按钮那一小块是客户端
// app/blog/page.tsx
import LikeButton from "./like-button";

export default async function Blog() {
  const posts = await getPosts(); // 服务端取数据
  return (
    &lt;ul&gt;
      {posts.map((p) =&gt; (
        &lt;li key={p.id}&gt;
          {p.title}
          &lt;LikeButton id={p.id} /&gt;{/* 只有它标了 "use client" */}
        &lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}

反过来的做法——在 app/blog/page.tsx 顶部直接写 "use client"——会让整页都变成客户端渲染,你前面赚到的首屏快、体积小全部还回去。这是新手最常见的一种「明明照抄了却更慢」。

四条边界,记成一张表

你想做的事该放哪边为什么 / 别搞错什么
读数据库、带密钥调接口服务端组件代码与密钥不下发浏览器。别为了省事把它挪进 "use client" 文件
页面的正文、标题、列表服务端组件搜索引擎要能直接读到。别把对外内容整页标成客户端
点击、输入框、开关、动画客户端组件这些必须在浏览器发生。尽量拆成小叶子,别整页标
读取浏览器专有信息(当前位置、窗口宽度、登录态里的本地存储)客户端组件服务器上没有这些概念。可以用 useEffect 在挂载后再读
💡 一句话记住

默认放服务端,只有报错说「这个不能在这用」时才拆 client。判断顺序是「先写服务端,跑起来报错再拆」,不要提前猜。

08路由:文件夹名就是网址

三类文件夹能覆盖 90% 的路由需求:普通嵌套、动态参数、分组。只要记住括号和方括号的区别。

嵌套:文件夹套文件夹

app/
├── page.tsx                      # → /
├── about/
│   └── page.tsx                  # → /about
└── blog/
    ├── page.tsx                  # → /blog(列表页)
    └── first-post/
        └── page.tsx              # → /blog/first-post(详情页)

动态参数:一个文件接住无数个网址

博客有几百篇文章,你不可能手工建几百个文件夹。把文件夹名写成方括号包起来的 [slug],就得到一个「占位」文件夹——里面任何值都进同一个文件。slug 是行业里对「网址里那一段用来标识内容的短名字」的叫法。

app/blog/[slug]/page.tsx   # → /blog/任意值 都进这个文件
⚠️ 16 版必须改的一处:参数是异步的

在 15 版之前,你可以直接写 params.slug。从 16 版起,同步取参数被彻底移除,只能先 await 再取。旧教程里清一色是同步写法,照抄必报错。正确的两种写法:

// 写法一(推荐):用官方命令生成的类型 helper
// 先在终端跑一次 npx next typegen,它会生成 PageProps 这个全局类型
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps&lt;"/blog/[slug]"&gt;) {
  const { slug } = await props.params; // 必须先 await
  return &lt;h1&gt;文章:{slug}&lt;/h1&gt;;
}
// 写法二:手写类型,效果一样,不依赖 typegen
// app/blog/[slug]/page.tsx
export default async function Post({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;; // 注意是 Promise,不是对象
}) {
  const { slug } = await params; // /blog/hello 就拿到 slug = "hello"
  return (
    &lt;main&gt;
      &lt;h1&gt;文章:{slug}&lt;/h1&gt;
      &lt;p&gt;真实项目里,这里用 slug 去数据库查文章正文。&lt;/p&gt;
    &lt;/main&gt;
  );
}

为什么会有这个改动?因为 Next.js 想让「取参数」和「取数据」的写法统一——两者都是「要等一下才有值」,那就用同一种语法(await)表达。代价是所有旧代码都要改一遍,这也是 16 版最集中的升级工作量。

分组:加一对括号,只归类、不进网址

app/
├── (marketing)/
│   ├── about/page.tsx          # → /about(括号不出现在网址里)
│   └── pricing/page.tsx        # → /pricing
└── (admin)/
    └── dashboard/page.tsx      # → /dashboard

用途是给不同板块套不同的外壳:营销页一个 layout、后台另一个 layout,而网址依然干净,不会多出 /marketing/about 这种前缀。

⚠️ 括号 vs 方括号,一个符号之差,行为完全不同

(admin) 圆括号 = 分组,只影响谁和谁共用一个外壳,网址里看不到它。
[slug] 方括号 = 动态参数,会匹配那一段位置上的任意值,并把它交给你。
写错一个符号,路由行为天差地别,而且报错往往不明显。这是新手排错时最该先检查的地方。

页面跳转的三件套

写法什么时候用可复制的形式
Link 组件用户点一下就跳(导航、列表项)<Link href="/blog">看博客</Link>
useRouter().push()代码里主动跳(表单提交成功之后)router.push("/done"),需要 "use client"
redirect()服务端直接跳(没登录就踢去登录页)redirect("/login"),来自 next/navigation

详情页的两个搭档:找不到就 404,找得到就提前生成

动态参数能接住任意网址,但用户可能乱输一个不存在的。这时要友好地显示 404,而不是让页面崩掉。另一个优化是:如果文章列表在构建时就是已知的,可以提前把页面生成好,用户打开就是秒开的静态页。

// app/blog/[slug]/page.tsx —— 404 兜底 + 预生成,一次配齐
import { notFound } from "next/navigation";

const posts: Record&lt;string, string&gt; = {
  "hello-nextjs": "欢迎来到 Next.js 的世界…",
  "first-deploy": "记录第一次上线的心情…",
};

// 构建时把这两篇提前生成好:用户打开就是秒开的静态页
export function generateStaticParams() {
  return Object.keys(posts).map((slug) =&gt; ({ slug }));
}

export default async function Post({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;      // 16 版:先 await
  const body = posts[slug];
  if (!body) notFound();              // 乱输的 slug 进 404,不炸站
  return (
    &lt;main&gt;
      &lt;h1&gt;{slug}&lt;/h1&gt;
      &lt;p&gt;{body}&lt;/p&gt;
    &lt;/main&gt;
  );
}
💡 记忆口诀

查得到就渲染、查不到就 notFound()、热门页提前 generateStaticParams()。详情页三件套,背下来走遍天下。

全站 404 页:not-found.tsx

上一节的 error.tsx 管的是「代码报错」,而 notFound() 触发的 404 归 not-found.tsx 管。放一个在 app/ 根目录,全站乱输网址都有体面提示:

// app/not-found.tsx —— 全站 404,先有它再谈体验
import Link from "next/link";

export default function NotFound() {
  return (
    &lt;main&gt;
      &lt;h1&gt;404 · 页面去火星了&lt;/h1&gt;
      &lt;p&gt;地址可能输错了,或者内容被搬走了。&lt;/p&gt;
      &lt;Link href="/"&gt;← 回首页&lt;/Link&gt;
    &lt;/main&gt;
  );
}

三个「兜底文件」的分工,别混

文件 / 函数触发时机为什么分开 · 别当什么用
loading.tsx数据还没到,正在等管「慢」;别用它显示错误,那不是它的职责
error.tsx代码抛出了异常管「坏」;必须是客户端组件,因为要能点「重试」
not-found.tsx你主动调了 notFound(),或网址根本不存在管「没有」;404 在业务上是正常结果,不该报成错误

09取数据:一个 async 函数就够

先破一个过时的印象:取数据不用 useEffect 了

如果你看过早几年的 React 教程,取数据的标准写法是一长串:先声明一个装数据的变量、再声明一个装「加载中」的变量,然后在 useEffect(React 里「页面加载后要做的事」的钩子)里发请求,最后还要处理依赖警告。二十行起步。

在 Next.js 的服务端组件里,这一整套都不需要:组件函数自己可以是异步的,直接 await 结果就用。

// app/blog/page.tsx —— 整个函数是 async 的,这就是服务端组件的特权
async function getPosts() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");
  if (!res.ok) throw new Error("文章加载失败");
  return res.json();
}

export default async function Blog() {
  const posts = await getPosts();  // 服务端拼好 HTML 才发给浏览器
  return (
    &lt;main&gt;
      &lt;h1&gt;博客列表&lt;/h1&gt;
      &lt;ul&gt;
        {posts.map((p: { id: number; title: string }) =&gt; (
          &lt;li key={p.id}&gt;{p.title}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/main&gt;
  );
}
// 对照:老的客户端写法(需要交互的搜索框才这么写)
"use client";
import { useState, useEffect } from "react";

export default function Posts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  useEffect(() =&gt; {
    fetch("/api/posts")
      .then((r) =&gt; r.json())
      .then((d) =&gt; { setPosts(d); setLoading(false); });
  }, []);
  if (loading) return &lt;p&gt;加载中…&lt;/p&gt;;   // 加载态还得自己画
  return &lt;ul&gt;{posts.map((p) =&gt; &lt;li key={p.id}&gt;{p.title}&lt;/li&gt;)}&lt;/ul&gt;;
}

缓存:默认不缓存了,要缓存得自己说

🚨 这是旧教程最危险的一处过时

很多教程教「fetch 默认会缓存,要新鲜数据才加 no-store」。这个前提已经反了。现在的基本形态叫「auto no cache」:开发时每次请求都会去取最新,构建时取一次并把页面静态化。缓存是选择加入的,不是默认的。(官方文档原话:“Caching is opt-in.”)

// 实时数据:每次请求都去取最新的(多数动态数据的正确选择)
const res = await fetch(url, { cache: "no-store" });

// 明确要缓存:告诉 Next.js 去用它自己的服务端缓存
const res3 = await fetch(url, { cache: "force-cache" });

// 折中:最多缓存 60 秒,之后后台悄悄更新(把旧的先给你,同时去取新的)
const res2 = await fetch(url, { next: { revalidate: 60 } });
写法含义什么时候用 · 别用错在哪
不写任何选项(默认)开发时每次都取最新;构建时取一次并静态化内容不常变、又想快——博客、文档。别以为它「每次请求都最新」,构建后它就是一份静态快照
cache: "no-store"每次请求都去远端取库存、在线人数、用户私有数据。别给高流量公开页用,会把服务器压力放大
cache: "force-cache"优先用服务端缓存,命中就直接返回几乎不变的数据。别对需要实时的数据用——你会看到过期内容
next: { revalidate: 60 }最多缓存 60 秒,之后后台悄悄重新生成(这套机制官方叫 ISR,即「增量静态再生」)新闻列表、排行榜这类「又快又不太旧」的场景。别拿它当实时方案的替代

需要交互的数据,还是放客户端

搜索框、筛选器这类「用户一动就要变」的地方,仍然用老办法(useEffect + fetch),或者用专门管这件事的库。下面那段代码里的「防抖」是这里唯一的新词:它指「用户停下来之后再发请求」——人连着敲字时只算最后一次,而不是每敲一下都打一次接口。原则一句话:首屏要展示的数据放服务端,需要即时交互的数据放客户端。

"use client"; // 客户端搜索框:输入即筛选,必须跑在浏览器
import { useState, useEffect } from "react";

export default function SearchBox() {
  const [q, setQ] = useState("");
  const [list, setList] = useState&lt;string[]&gt;([]);

  useEffect(() =&gt; {
    if (!q) { setList([]); return; }
    const t = setTimeout(async () =&gt; {
      const res = await fetch(`/api/search?q=${q}`);
      const data = await res.json();
      setList(data.items);
    }, 300);                      // 防抖 300ms:别每敲一个字母就请求一次
    return () =&gt; clearTimeout(t); // 清掉上一次的定时器
  }, [q]);

  return (
    &lt;div&gt;
      &lt;input value={q} onChange={(e) =&gt; setQ(e.target.value)} placeholder="搜点什么…" /&gt;
      &lt;ul&gt;
        {list.map((item) =&gt; (
          &lt;li key={item}&gt;{item}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}
⚠️ 客户端取数据的三个注意

① 防抖别省:加了 300 毫秒的等待,用户连着敲字时只在停下后请求一次,否则输入法每敲一下都打一次接口。
② 加载态和空状态都要画:别只画「有数据」的样子,否则用户不知道是卡住了还是没结果。
③ 密钥绝不能出现在这个文件里:标了 "use client" 的代码会下发到浏览器,谁都看得到。

放进哪一边:一张决定表

数据的样子放哪理由
文章正文、商品详情服务端要能被抓取到,也要快
需要密钥才能调的接口服务端密钥不下发浏览器,这是安全底线
搜索联想、实时筛选客户端用户一边敲一边要变,服务端接不住这种频率
用户的私有草稿、本地偏好客户端和服务端渲染无关,且不该被缓存成公共页面

10进阶三件套:写操作、接口、请求拦截

学会这三样,你就能独立做完一个带写操作的完整站点。

一、Server Actions:表单不用再写接口了

先讲它是什么:一个写在服务端的普通函数,函数顶部加一行 "use server",然后表单可以直接把它当成提交目标。「Action」在这里就是「提交时要做的那件事」。

对比一下省了什么:老写法要前端发请求 → 后端接口接住 → 校验 → 写库,前后两个文件、两次数据搬运。现在一个文件、一个函数。

// app/guestbook/actions.ts —— 服务端函数
"use server";
export async function addMessage(formData: FormData) {
  const text = formData.get("text");
  // 这里可以直接写数据库,密钥不会泄露到浏览器
  console.log("收到留言:", text);
}
// app/guestbook/page.tsx —— 表单直接绑定它
import { addMessage } from "./actions";

export default function Guestbook() {
  return (
    &lt;form action={addMessage}&gt;
      &lt;input name="text" placeholder="说点什么…" /&gt;
      &lt;button type="submit"&gt;留言&lt;/button&gt;
    &lt;/form&gt;
  );
}
📖 为什么这个组合好用

注意表单里没有 onSubmit、没有 fetch、没有 e.preventDefault()。表单在 JavaScript 还没加载完时也能提交(浏览器原生就支持表单提交),这对网络慢的用户是实打实的体验差别。这也是它相比「自己写 fetch」最容易被忽略的优势。

⚠️ 边界:别把它当万能后端

留言、评论、点赞这类小写入非常适合。但传文件、处理大文件、长时间运行的任务还是走专门的接口,别塞在这里。

写完之后页面还是旧的?用 revalidatePath

Server Action 写完数据库后,页面可能还在显示缓存的旧内容。要告诉 Next.js「这一页脏了,重做一份」:

"use server";
import { revalidatePath } from "next/cache";

export async function addMessage(formData: FormData) {
  const text = formData.get("text");
  // …把 text 写入数据库…
  revalidatePath("/guestbook"); // 留言板页立即更新,不用等缓存过期
}
📖 顺便知道一下

16 版还新增了一个更「即时」的同类 API 叫 updateTag,语义是「改完立刻让用户看到自己的改动」。新手先用熟 revalidatePath 就够,遇到「缓存按标签管理」的项目再学标签那一套。

二、Route Handlers:正经写一个 JSON 接口

在 app/api/ 下面建文件夹,里面放一个叫 route.ts 的文件,导出 GET、POST 这样的函数——它就变成了标准接口,可以被小程序、手机 App 或第三方调用。

// app/api/hello/route.ts —— 访问 /api/hello 得到 JSON
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({ message: "你好,我是 Next.js 接口" });
}

export async function POST(request: Request) {
  const body = await request.json();
  return NextResponse.json({ received: body });
}

三、请求拦截:middleware.ts 已经改名叫 proxy.ts

🚨 16 版的第二处大改动

官方文档现在明确写着:middleware 这个文件约定已弃用,改名为 proxy;导出的函数也从 middleware 改名为 proxy;而且它默认运行在完整的 Node.js 运行时,不再是那个连数据库都连不上的轻量边缘环境。

如果你在旧项目里写 middleware.ts,它还能跑但会有弃用提醒;新项目请直接用 proxy.ts。这解释了为什么你搜到的教程和官方文档对不上——不是谁写错了,是版本变了。

// proxy.ts —— 放在项目根目录(和 app/ 同级)
// 保护 /dashboard:没登录就放行到登录页
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const loggedIn = request.cookies.get("token");
  if (!loggedIn &amp;&amp; request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next(); // 放行
}

// 只对 /dashboard 开头的路径生效;不写 matcher 会对每个请求都跑
export const config = { matcher: ["/dashboard/:path*"] };
⚠️ matcher 不写会出事

官方文档专门提醒:不写 matcher 时,代理会对每一个请求都执行,包括静态文件、图片优化请求和 public/ 里的资源。结果就是你的登录拦截可能把 CSS、JS、图片一起拦掉,页面样式全崩。所以要么写 matcher,要么用否定匹配把静态资源排除掉。

三件套怎么选

你的需求用谁一句话理由 · 别用错在哪
表单提交、点赞、评论Server Actions零接口文件,写个函数就行。别用它传大文件
给外部或跨端用的 JSON 接口Route Handlers用的是 REST 这套通行约定:网址代表「东西」,请求方法(GET 取、POST 交)代表「对它做什么」——别人照着约定就能接进来。站内表单别多此一举走它
登录拦截、地区跳转、A/B 分流proxy(旧名 middleware)请求进门前先过安检。别在里面做重计算——它是每个请求都要跑的

流式渲染:先出壳、再填肉

页面里有的部分快、有的部分慢(比如评论区要查数据库)。别让慢的拖住整页——用 Suspense 包住慢的那块,快的先显示,慢的到了自动补上。

// app/blog/[slug]/page.tsx —— 正文秒开,评论慢慢到
import { Suspense } from "react";

export default async function Post(props: PageProps&lt;"/blog/[slug]"&gt;) {
  const { slug } = await props.params;   // 16 版:先 await
  return (
    &lt;main&gt;
      &lt;h1&gt;{slug}&lt;/h1&gt;
      &lt;p&gt;正文内容(快的,先出)&lt;/p&gt;
      &lt;Suspense fallback={&lt;p&gt;评论加载中…&lt;/p&gt;}&gt;
        &lt;Comments slug={slug} /&gt;{/* 慢的组件,后到 */}
      &lt;/Suspense&gt;
    &lt;/main&gt;
  );
}

async function Comments({ slug }: { slug: string }) {
  await new Promise((r) =&gt; setTimeout(r, 2000)); // 模拟慢查询
  return &lt;p&gt;{slug} 的评论区(2 秒后出现)&lt;/p&gt;;
}
💡 它和 loading.tsx 的关系

loading.tsx 是整页级的占位;Suspense 是组件级的占位。整页慢用前者,局部慢(评论区、推荐位)用后者。两个配合,体验最好。

11把零件拼成一个站:三个练习和一条路线

前面每一节给的都是零件。这一节把它们拼起来。建议按顺序做三个练习,每个只比上一步多一个能力。

练习一:个人博客(只读——先跑通路由和数据)

// app/blog/page.tsx —— 列表页骨架(先用假数据,跑通再说)
import Link from "next/link";

const posts = [
  { slug: "hello-nextjs", title: "你好,Next.js" },
  { slug: "my-first-deploy", title: "第一次部署上线" },
  { slug: "server-components", title: "搞懂服务端组件" },
];

export const metadata = { title: "博客 | 我的小站" };

export default function Blog() {
  return (
    &lt;main&gt;
      &lt;h1&gt;博客&lt;/h1&gt;
      &lt;ul&gt;
        {posts.map((p) =&gt; (
          &lt;li key={p.slug}&gt;
            &lt;Link href={`/blog/${p.slug}`}&gt;{p.title}&lt;/Link&gt;
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/main&gt;
  );
}

配套的详情页用第 08 节那份 [slug]/page.tsx(含 notFound() 和 generateStaticParams())。做完你会有:列表、详情、404 兜底、预生成。注意 params 必须 await。

练习二:留言板(读写闭环——加一个 Server Action)

// app/guestbook/page.tsx —— 读 + 写同页
import { addMessage } from "./actions";

const messages = ["第一条留言:这个网站真快!", "第二条:学会了文件即路由"];

export default function Guestbook() {
  return (
    &lt;main&gt;
      &lt;h1&gt;留言板&lt;/h1&gt;
      &lt;ul&gt;
        {messages.map((m, i) =&gt; (
          &lt;li key={i}&gt;{m}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
      &lt;form action={addMessage}&gt;
        &lt;input name="text" placeholder="说点什么…" /&gt;
        &lt;button type="submit"&gt;留言&lt;/button&gt;
      &lt;/form&gt;
    &lt;/main&gt;
  );
}

配套的 app/guestbook/actions.ts 用第 10 节那份(含 revalidatePath)。这一步之后你就有了「读 + 写」的完整闭环。

练习三:待办清单(增删改——第一次亲手管状态)

前两个练习都是服务端为主,这个专练客户端。顺手理解一件事:状态就是「会变的记忆」。

"use client"; // 待办清单:全是点击和输入,必须是客户端组件
import { useState } from "react";

type Todo = { id: number; text: string; done: boolean };

export default function Todos() {
  const [todos, setTodos] = useState&lt;Todo[]&gt;([]);
  const [input, setInput] = useState("");

  function add() {
    if (!input.trim()) return; // 空输入直接忽略
    setTodos([...todos, { id: Date.now(), text: input, done: false }]);
    setInput("");
  }
  function toggle(id: number) {
    setTodos(todos.map((t) =&gt; (t.id === id ? { ...t, done: !t.done } : t)));
  }
  function remove(id: number) {
    setTodos(todos.filter((t) =&gt; t.id !== id));
  }

  return (
    &lt;main&gt;
      &lt;h1&gt;我的待办&lt;/h1&gt;
      &lt;input
        value={input}
        onChange={(e) =&gt; setInput(e.target.value)}
        placeholder="输入后点新增"
      /&gt;
      &lt;button onClick={add}&gt;新增&lt;/button&gt;
      &lt;ul&gt;
        {todos.map((t) =&gt; (
          &lt;li key={t.id}&gt;
            &lt;span onClick={() =&gt; toggle(t.id)}&gt;
              {t.done ? "✅ " : "⬜ "}
              {t.text}
            &lt;/span&gt;
            &lt;button onClick={() =&gt; remove(t.id)}&gt;删&lt;/button&gt;
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
      &lt;p&gt;共 {todos.length} 项,已完成 {todos.filter((t) =&gt; t.done).length} 项&lt;/p&gt;
    &lt;/main&gt;
  );
}
📖 从这个练习里理解「状态」

todos 是那份「会变的记忆」,setTodos 是唯一的修改入口。点一次按钮 → 调 setTodos → 页面自动重画。所有 React 交互都是这个循环,没有例外。看懂这一点,你就跨过了 React 最大的一道坎。

合体:把三个练习拼成一个站

app/
├── (marketing)/          # 营销页组:网址里看不到括号
│   ├── page.tsx            # → / 首页:自我介绍 + 三个入口
│   ├── about/page.tsx      # → /about
│   └── layout.tsx          # 营销页专用外壳
├── (app)/                # 应用页组:后台、设置
│   ├── dashboard/page.tsx  # → /dashboard
│   └── layout.tsx          # 带侧边栏的外壳
├── blog/
│   ├── page.tsx            # → /blog 列表
│   └── [slug]/page.tsx    # → /blog/任意值 详情
├── guestbook/            # 留言板:page.tsx + actions.ts
├── todos/page.tsx        # 待办:客户端组件
├── api/hello/route.ts    # JSON 接口
└── layout.tsx            # 根外壳:导航、字体、全局样式
components/                   # 跨页面复用的 UI(按钮、卡片)
lib/                          # 工具函数、数据库连接
public/                       # 静态资源

练习路线:每一步都是上一步的自然延伸

顺序做什么你会新学到
1博客(只读)文件即路由、动态参数、服务端取数据
2留言板(读写)Server Actions、缓存刷新
3待办清单(增删改)客户端组件、状态、事件
4接真实数据库把假数组换成真实查询
5加登录proxy 拦截、会话管理

七条军规,越早养成越省事

规则为什么违反的典型症状
服务端优先,交互才下沉每多标一个 "use client",首屏就重一点页面打开变慢,但你找不到是哪一行造成的
取数据放服务端首屏数据在服务端 await;密钥只活在服务端密钥出现在浏览器网络请求里(等于公开)
图片永远用 Image原生 <img> 又大又会造成页面抖动图片加载完页面内容突然往下跳
每个页面写 metadata五行代码换搜索排名和分享卡片分享到微信/社交平台是一片空白
上线前必跑 builddev 能跑 ≠ build 能过推上去之后线上部署失败
params 记得 await16 版删掉了同步写法params.slug 是 undefined,或类型报错
让 AI 读版本匹配的文档脚手架自带的 AGENTS.md 就是干这个的AI 给你写 middleware.ts 和同步 params

提交代码前的自查七问

① npm run build 本地过了吗?
没跑过就不要推送。它同时做类型检查和路由校验,是唯一可信的「能不能上线」信号。
② 新增的页面写 metadata 了吗?
五行代码,直接换搜索排名和分享卡片效果,成本几乎为零。
③ 密钥只在服务端吗?
全局搜一下 key / secret / token,确认它们没出现在标了 "use client" 的文件里。
④ 图片都用 Image 了吗?
顺手把原生 <img> 换掉,同时记得写 width 和 height。
⑤ 交互组件拆得够小吗?
"use client" 应该只盖在叶子上,不该盖在整页上。
⑥ 404 和加载占位有了吗?
各十行左右,体验差别很大,属于性价比最高的一类投入。
⑦ 控制台和终端干净吗?
黄色警告顺手清掉。留着它们,以后真报错时你会分不清哪条是新的。

12部署上线:推代码就是上线

代码只在自己电脑上跑不叫作品,别人能打开才叫上线。这一节给你两条路,先走快的那条。

上线前,本地先过三关

# 第一关:生产构建能不能过
npm run build
# 期望看到 Compiled successfully / Generating static pages
# 报错就从第一条红色错误开始修,多半是类型问题或 params 没 await

# 第二关:以生产模式跑起来,亲手点一遍
npm start
# 打开 http://localhost:3000 验收

# 第三关:确认没有把测试地址、私钥硬编码进代码(搜一遍再上线)
⚠️ 最常见的一种翻车

npm run dev 一切正常,npm run build 直接挂。原因是 build 会做全量类型检查和路由校验,而 dev 为了快会放过一些东西。所以「能 dev 不能 build」不是玄学,是必然。

方案 A:Vercel 一键上线(新手首选)

1
把代码推到 GitHub

建一个仓库,把项目推上去。不会 Git 的话先补一下最基本的 add / commit / push 三步。

2
在 Vercel 导入仓库

打开 vercel.com,用 GitHub 账号登录,点 Add New → Project,选中你的仓库,一路默认,点 Deploy。

3
拿到网址发给朋友

两分钟左右你会得到 xxx.vercel.app。之后每次 git push,它自动重新部署——这就是「推代码即上线」。

💡 个人项目先别买套餐

个人博客、作品站这类小流量项目,免费额度完全够用。先上线,再优化;等流量真的涨了再考虑付费。

方案 B:自己服务器 / Docker 部署

公司内网、有特殊合规要求时用。核心只有三步:

npm run build     # 在服务器上打包
npm start         # 启动生产服务,默认 3000 端口
# 再配一个反向代理(Nginx / Caddy)把域名指过来,并加上 HTTPS

环境变量:密钥怎么放才不泄露

# .env.production —— 生产环境的密钥放这里,不进 git
DATABASE_URL="postgresql://..."
NEXT_PUBLIC_SITE_URL="https://mysite.com"
⚠️ 一条不能忘的命名规则

「打包」在这里指:把浏览器要跑的那部分代码和服务端要跑的那部分分开整理。只有以 NEXT_PUBLIC_ 开头的变量,才会被打进浏览器那一份里(也就是任何人按 F12 都能看到)。所以:数据库密码、支付密钥这类东西,绝对不能加这个前缀——加了就等于把它们公开在网页源码里。反过来,需要在前端代码里读到的站点地址,就必须加。

两条路怎么选

维度Vercel(方案 A)自建服务器(方案 B)
上手难度点几下鼠标要懂 Node、反向代理(挡在你自己程序前面、负责转发请求的那层软件)、HTTPS(把浏览器和服务器之间的传输加密,地址栏出现小锁)三样
费用个人小站免费额度够用云服务器按月付费
HTTPS 证书自带,自动续期自己配(Caddy 最省事)
适合谁新手、个人项目、快速验证想法有运维要求的团队、内网项目

上线后第一件事:看日志

你的部署方式日志在哪看为什么必须看
Vercel项目面板 → Deployments → 某次部署 → Runtime Logs页面 500 时,真正的报错只在这里,前端看不到
自建npm start 那个终端的输出;生产建议用 pm2 start npm -- start 托管进程挂了会自动重启,日志不会随终端关闭而丢

规矩一句话:页面出错先看日志第一行,再回本地复现,别靠猜。

绑定自己的域名

1
买域名

任意域名商都行,搜一个喜欢的名字下单,一年几十块。

2
在 Vercel 里添加它

项目 → Settings → Domains,输入你的域名,按提示去域名商后台加一条 DNS 记录(通常是一条 CNAME)。

3
等生效

DNS 生效要几分钟到几小时。之后访问自己的域名就是你的网站,证书自动续期,不用管。

📖 怎么算「真的上线成功」

① 手机关掉 Wi-Fi 用流量也能打开(排除只是本机缓存);② 所有页面点一遍没有 500;③ 在浏览器里「查看网页源代码」,能看到文章正文的文字(这是服务端渲染生效的直接证据)。

13常见坑与报错速修

新手九成的卡点集中在这一张表里。按报错查,不要从第一条开始读全文。

按现象查:十个高频坑

你看到的现象真实原因怎么修
useState is not allowed in Server Components 之类在默认的服务端组件里写了交互把用到交互的那一小块拆成独立文件,顶部加 "use client"。加在叶子上,别整页标
params.slug 是 undefined,或类型报错说它不是对象16 版参数是异步的,同步取法已被删除改成 const { slug } = await params;,类型写 Promise<{ slug: string }>
访问路径 404,但文件明明存在路由靠的是 page.tsx,不是文件夹本身确认那个文件夹里有 page.tsx;再检查大小写,Page.tsx 无效
改了 middleware.ts 却没生效16 版已把它改名成 proxy.ts,函数名也改成 proxy文件改名为 proxy.ts、导出函数改名为 proxy;或跑官方 codemod
改了代码页面没变化三连查:dev 还在跑吗 / 改的是不是当前访问的那个路由 / 是不是被缓存了先看终端有没有报错;再确认改的文件属于当前网址;必要时重跑 npm run dev
npm run build 挂了,dev 却好好的build 做全量类型检查:未用变量、隐式 any、类型不匹配都会拦滚到最顶看第一条红色 Error,修完再跑。后面的报错常常会一起消失
图片不显示或页面抖动路径或宽高没给图片放 public/,src 用 / 开头;Image 必须给 width/height;远程图片要在 next.config.ts 里配域名白名单
终端里一堆英文报错,不知从哪看起——只看第一条。滚到最顶找第一个红色的 Error:,它后面的文件名和行号就是案发现场
Port 3000 is in use上一次的 dev 没关换端口:npm run dev -- -p 3001,然后开 localhost:3001
Tailwind 的类名写了没效果三连查:全局样式有没有被引入 / 类名有没有拼错 / dev 要不要重启确认根 layout.tsx 里 import "./globals.css";类名大小写敏感(text-3xl 不是 text-3XL)

从旧版 pages/ 迁移过来时的水土不服

如果你在别人的老项目里看到下面这些名字,要知道它们已经被取代了。别逐行翻译,重写更顺:

旧写法(pages/ 路由)现在用什么为什么
getServerSideProps服务端组件里直接 await同一个文件里取数据,少一层数据传递
getStaticProps默认静态化,或 generateStaticParams()不再需要手动声明「我要静态生成」
_app.tsx / _document.tsxapp/layout.tsx用一层可嵌套的 layout 替代两个特殊文件
middleware.tsproxy.ts16 版改名,且默认跑完整 Node.js 运行时

大版本升级:用官方工具,别手改版本号

# 1. 先提交干净代码(留一份能回滚的档)
git add -A &amp;&amp; git commit -m "before upgrade"

# 2. 跑官方升级工具,它会自动改掉大部分机械性变动
#    (注意:它不会跑全部迁移,同步 params 那类还要单独跑一次 async 迁移)
npx @next/codemod@canary upgrade latest

# 3. 过一遍官方升级指南里的破坏性改动,再 build 验证
npm run build

英文报错高频五句

报错里的关键词多半是先查哪里
Module not found路径写错了检查 import 的路径和文件名大小写
Hydration failed服务端和浏览器渲染出的结果不一致检查是不是用了随机数、当前时间这类两边不同的值
params / Promise 相关参数没 await看第 08 节那两种正确写法
404缺 page.tsx,或路径拼错回到第 06 节对一遍四个文件名
Port in use端口被占换端口,或关掉旧的 dev 进程
🚫 绝对禁止

把数据库密码、支付密钥、第三方 API 密钥写进标了 "use client" 的文件,或任何浏览器能看到的地方。它们只能出现在服务端组件、Server Actions、Route Handlers 里,或者以不带 NEXT_PUBLIC_ 前缀的环境变量形式存在。

14命令小抄

贴在显示器边上,忘了就来翻。

命令

命令作用什么时候用
node -v看 Node 版本装任何东西之前;要 ≥ 20.9.0
npx create-next-app@latest my-app建新项目每次开新项目
npm run dev开发模式,带热更新写代码全程
npm run build打包 + 全量检查推送代码前;判断能不能上线
npm start跑生产构建的结果本地预览上线效果(先 build)
npm run dev -- -p 3001换端口启动3000 被占用时
npx next typegen生成 PageProps 这类类型 helper想让 await params 有完整类型提示时
npx @next/codemod@canary upgrade latest官方自动升级升大版本时先跑它
npx @next/codemod@canary agents-md给旧项目补 AI 说明书想让 AI 按当前版本文档写代码时

文件约定 → 网址

文件网址效果别忘了什么
app/page.tsx/没有它首页就是 404
app/about/page.tsx/about文件夹里必须有 page.tsx
app/blog/[slug]/page.tsx/blog/任意值params 要 await
app/(admin)/dashboard/page.tsx/dashboard圆括号不进网址
app/api/hello/route.ts/api/hello导出的是 GET/POST 函数
app/blog/loading.tsx博客分区的加载占位它是整页级的占位
app/blog/error.tsx博客分区的出错兜底必须标 "use client"
proxy.ts(旧名 middleware.ts)不产生网址,拦截所有匹配的请求16 版改名;导出函数叫 proxy;记得写 matcher

数据、跳转与写操作

你要做的事写法要点
在服务端取数据const data = await fetch(url)写在 async 的服务端组件里
每次都取最新fetch(url, { cache: "no-store" })缓存现在是选择加入的
定时更新(ISR)fetch(url, { next: { revalidate: 60 } })60 秒后后台更新
明确要缓存fetch(url, { cache: "force-cache" })只给几乎不变的数据用
点击跳转<Link href="/about">站内用 Link,站外用 <a>
代码里跳转router.push("/done")需要 "use client"
服务端跳转redirect("/login")来自 next/navigation
写操作(表单)"use server" 函数 + 表单 action写完记得 revalidatePath
查不到的动态页notFound()配合 not-found.tsx
详情页提前生成generateStaticParams()已知有哪些页面时用

环境变量

需求写法为什么
只在服务端用的密钥DATABASE_URL="..."(不加前缀)不会下发到浏览器
浏览器也要读的值NEXT_PUBLIC_SITE_URL="..."必须有这个前缀才会被打包进前端
改完数据刷新页面缓存revalidatePath("/guestbook")不刷的话页面还是旧内容

15术语对照表

遇到看不懂的词回来查。第三列是为什么重要、什么时候用、别当什么用——比一句翻译有用得多。

术语定义(说人话)为什么重要 / 何时用 · 别当什么用
App Router由 app/ 目录驱动的新一代路由体系新项目默认用它,本文全部基于它;别和旧 pages/ 的写法混着抄
服务端组件(RSC)在服务器上执行、默认不向浏览器发送 JavaScript 的组件首屏内容、读数据库、带密钥的调用都放这里;别在里面写 onClick
客户端组件文件顶部标 "use client",在浏览器里恢复交互的组件点击、输入、动画才用,且尽量拆到叶子;别整页标 client,那会把服务端优势全丢掉
注水(Hydration)服务端发来的静态 HTML 到浏览器后,绑定上交互能力的过程理解「为什么首屏快、后面才可点」的关键;别把它当成「页面渲染了两次」的 bug
文件即路由app/ 下的文件夹结构直接决定网址建页面 = 建文件夹 + page.tsx;别再去找路由配置文件,那东西不存在
动态路由 [slug]方括号文件夹,一个文件匹配无数个网址博客详情、商品页用它;别为每篇文章手建一个文件夹
params动态路由从网址里抓出来的那一段值16 版起它是要 await 的;别写 params.slug,那是已删除的旧写法
路由组 (name)圆括号文件夹,只做归类、不出现在网址里不同板块要套不同 layout 时用;别指望它出现在地址栏
Server Actions"use server" 标记的服务端函数,表单可以直接调用留言、点赞这类写操作零接口成本;别拿它做重计算或大文件上传
Route Handlersapp/api/*/route.ts 里导出 GET/POST 的标准接口给外部或跨端用的 JSON 接口;站内表单优先用 Server Actions
proxy(旧名 middleware)请求进入页面之前先跑的拦截器登录拦截、地区跳转、A/B 分流;16 版改名为 proxy,别在里面连数据库做重活
ISR(增量静态再生)页面先按静态发出去,过期后在后台重新生成新闻榜单这类「又快又不太旧」的场景;实时库存别用它
缓存(cache)把取过的结果存起来,下次直接用现在默认不缓存,要缓存得自己声明;别以为不写选项就一定是「每次最新」
SSR / SSG / CSR服务端每次渲染 / 构建时预先生成 / 浏览器里渲染三种取舍:SSR 首屏好但耗服务器,SSG 最快最便宜但不适合实时数据,CSR 适合强交互内页但对外页吃 SEO 的亏
TurbopackRust 写的打包与开发引擎,16 版起是默认享受极速热更新;它只是引擎不是框架,构建出问题先看版本和配置,别乱改
Metadata(元数据)每个页面导出的、描述这一页的标题与摘要对象搜索引擎排名和分享卡片全靠它,每页顺手写;别堆关键词作弊
流式渲染 / Suspense快的部分先发出去,慢的部分随后补上评论区、推荐位这类慢组件用它;整页都慢应该先优化查询,别拿它遮丑
预取(Prefetch)用户还没点,就提前把目标页下载好Link 默认自带,跳转如飞;别特意关掉它
next.config.tsNext.js 的总配置文件配远程图片域名、重定向、Turbopack 选项都在这;改完要重启 dev
public/ 目录放图片等静态文件的篮子引用时用 / 开头的路径;别把源码和密钥放进来,它会被原样公开
环境变量(env)区分开发和生产配置的键值对不带 NEXT_PUBLIC_ 前缀的只活在服务端;浏览器要读的才加前缀
VercelNext.js 背后的公司做的部署平台新手上线首选,推送即部署;别把它和 Next.js 本身搞混,框架免费且能部署到别处
Codemod官方写的自动改代码脚本,用来做跨版本迁移升大版本先跑它,省掉大量手工改动;跑完务必 build 验证,别直接上线
runtime(运行时)代码实际执行的那个环境服务端组件的运行时是 Node.js,客户端的运行时是浏览器;搞清楚「这行在哪跑」是排错的第一步

99一句话总结

🎯 核心要点

Next.js 就是用 React 一次搞定网站的前台和后台:文件夹即路由、组件默认在服务器上跑、表单能直接调用服务端函数;照着「建文件 → 填数据 → 拆交互 → 跑 build」四步走,今晚就能上线你的第一个网站。

带走这三句

  1. 文件夹就是网址——建页面先建文件夹,里面必须有 page.tsx。
  2. 默认放服务端,交互才下沉——分不清时先全写服务端,报错再拆出 "use client"。
  3. 先 build 再上线——本地 build 过了,上线就稳了一大半。

现在就动手

打开终端,跑这两行,把第 05 节的两个页面亲手敲出来:

npx create-next-app@latest my-app
cd my-app &amp;&amp; npm run dev

遇到红色报错,把完整报错连同你用的 Next.js 版本一起发给 AI 助手。你已经学会了最难的那一步:开始。

练习:把这个教程变成你自己的

下面这份提问模板可以直接复制去问 AI,它保证 AI 按 16 版的写法回答你(尤其是 await params 和 proxy 这两处):

我想用 Next.js(App Router,v16.3)做一个【博客 / 留言板 / 待办(三选一)】。
请按以下要求生成代码:
1. 文件放在 app/xxx/page.tsx,TypeScript + Tailwind
2. 服务端组件优先,只有交互部分用 "use client"
3. 动态路由的 params 必须按 v16 写法 await,不要给同步写法
4. 如果要写请求拦截,用 proxy.ts 而不是 middleware.ts
5. 每页导出 metadata 标题
6. 告诉我每一步在终端敲什么命令验证
下面是我的报错(如有,粘贴完整的红色文字):【粘贴处】
💡 最后一句给你的话

留一个只有你自己能验的作业:用你现在手头真实的一个小需求,套用本文的四步走一遍。如果你能不看文章就说清楚「这个文件是在服务器上跑还是在浏览器里跑」,那这篇教程对你才算真的读完了。

97新手最常问的八个问题

零基础能直接学 Next.js 吗?还是必须先学 React?
能直接上手做东西(本文就是零起点写的),但建议并行补 React 的四样基础:组件、props(从外面传给组件的参数)、JSX、useState。先会抄,再懂原理,学两遍最快。完全不会 JavaScript 的话,先补变量、函数、数组、对象,再回来。
TypeScript 必须用吗?我只会 JavaScript。
不是必须。脚手架默认给 TypeScript,但你完全可以按 JavaScript 的习惯写,类型能省就省。等报错逼你学时再补——脚手架不会拦你。只是有了类型,拼写错误会在你运行之前就被指出来。
App Router 和 Pages Router 到底学哪个?
新项目只学 App Router(app/ 目录)。pages/ 那套只在接手老项目时查文档现学。官方的新能力——服务端组件、新的缓存模型、新的 proxy——全部先在 App Router 上落地。两套混着抄是新手最大的乱源。
Next.js 只能部署到 Vercel 吗?会不会被绑定?
不会。Vercel 是最顺的一条路(推送即上线),但你 npm run build 之后,任何能跑 Node 的服务器、Docker、其他云平台都能部署。框架本身是开源免费的。
跟着教程敲完,代码能让 AI 帮我写吗?
能,而且推荐。但要给它两样东西:版本匹配的官方文档链接、以及脚手架自带的 AGENTS.md。直接说「按 Next.js 16 App Router 的最新写法生成,params 要 await,拦截用 proxy.ts」,出错的概率会低很多。第 15 节末尾有一份可以直接复制的提问模板。
学完这篇,下一步学什么?
按这个顺序:① 独立做出博客 + 留言板两个练习;② 接真实数据库(Supabase 这类最省事);③ 加登录;④ 部署到 Vercel 发给朋友看。每步都是上一步的自然延伸,别一次全上。
版本更新这么快,我现在学的会不会很快过时?
三套核心心智——文件即路由、组件默认在服务端、数据在服务端取——从 13 版到 16 版一直稳定,可以放心投入。真正在动的是缓存开关和性能选项。真遇到大版本升级时,用官方 codemod 一键迁移(命令在第 14 节小抄里)。本文的版本锚点在页面顶部与文末都标了。
Tailwind 是必须的吗?我更习惯写 CSS。
不是必须。脚手架里选 No 就行,然后用普通 CSS 照常写。Tailwind 只是让新手少纠结样式,好处是「不用在两个文件之间来回跳」。它有它自己的类名要记,这是它的成本。
英文报错完全看不懂怎么办?
先按第 13 节的「英文报错高频五句」表对号入座,覆盖了八成情况。最高效的做法是:把完整的红色报错(从 Error: 那一行到最后一行)整段贴给 AI 助手,不要只截一句——上下文丢了,它只能猜。

98参考来源与证据边界

本文的关键事实均在 2026-09-13 逐条核对过以下来源:

#来源核对了什么
[1]Next.js 官方文档 nextjs.org/docs当前文档标注的 Latest Version 为 16.3.5;App Router 的章节结构(Server and Client Components、Fetching Data、Proxy、Cache Components 等)
[2]官方安装文档 /docs/app/getting-started/installation环境要求、create-next-app 流程、四个特殊文件名、目录结构导览
[3]create-next-app CLI 参考 /docs/app/api-reference/cli/create-next-app提问顺序与选项:推荐默认配置、linter 三选一(ESLint / Biome / None)、React Compiler、src/、别名、--agents-md
[4]16 版升级指南 /docs/app/guides/upgrading/version-16Node.js 最低 20.9.0、TypeScript 最低 5.1.0、Turbopack 成为默认、同步取参数被彻底移除、middleware → proxy、revalidateTag 现在要求第二个参数
[5]proxy.js 参考 /docs/app/api-reference/file-conventions/proxy原文声明「middleware 文件约定已弃用并改名为 proxy」;函数名 proxy;不写 matcher 会对所有请求生效
[6]动态段参考 /docs/app/api-reference/file-conventions/dynamic-routes当前正确定义:params: Promise<{ slug: string }> 并 await params
[7]fetch 参考 /docs/app/api-reference/functions/fetch默认行为为 auto no cache;官方原话「Caching is opt-in.」;force-cache / no-store / next.revalidate 的语义
[8]npm registry registry.npmjs.org/next/latest当前最新稳定版 16.3.5;engines.node 为 >=20.9.0
[9]GitHub 仓库 github.com/vercel/next.js仓库简介原文「The React Framework」;Stars 约 142k;v16.3.5 发布于 2026-09-11
📖 证据边界与时效说明

本文的版本锚点是 Next.js 16.3.5(2026-09-13 校对)。需要留意的边界有三处:

① 缓存模型正在迁移。16 版里同时存在两套缓存心智:老的一套(fetch 选项、revalidatePath)和新的一套 Cache Components(在配置里打开 cacheComponents,用 use cache 指令)。本文讲的是老的那套,因为它仍然是当前没有额外开启时的实际行为,也是绝大多数项目在用的。等你的项目要开 Cache Components 时,请以官方 Caching 那一节为准。

② 「缓存细节」是这个版本里唯一真正在动的部分。你学到的三套核心心智——文件即路由、组件默认在服务端、数据在服务端取——从 13 版到 16 版一直稳定,可以放心投入。变的多数是缓存开关和性能选项。

③ 来源矛盾的处理。网络上大量教程(包括 AI 的记忆)仍在写同步 params 与 middleware.ts,与官方当前文档矛盾。本文一律以官方文档为准,并已在第 07、08、10、13 节点名指出这两处差异——这不是谁抄错了,是 16 版确实改了。

这 4 个链接建议收藏

链接什么时候去
nextjs.org/docs卡住了先来这搜;注意顶部要在 App Router / Pages Router 之间选对
安装页环境要求、脚手架选项、目录结构都在这
nextjs.org/learn官方免费互动教程,学完本文去那练手感
仓库 Discussions疑难杂症先搜这里,大概率有人踩过同一个坑
⌂