我们会从一个空目录开始,逐步做出“拾光书架”:它有首页、书架、图书详情、搜索、收藏和共读书摘。项目不靠零散示例拼接,后面每一次修改都会接在前一次结果上。
这门课默认你见过 HTML、CSS、JavaScript 和 React 的基础写法。如果 div、函数组件、属性与数组 map 还很陌生,可以先补一遍 React 基础;Next.js 会复用这些知识,再增加路由、服务器渲染、数据读取和构建部署。

早期网页主要是一批 HTML 文件。浏览器向服务器请求一个地址,服务器找到文件并返回,浏览器再把 HTML、CSS 和图片画出来。页面结构简单时,这种方式直接、可靠。
JavaScript 进入浏览器后,网页开始响应点击、更新局部内容。后来,单页应用把更多工作移到浏览器:服务器先返回一个较小的 HTML 外壳,再由 JavaScript 创建界面。React 在 2013 年公开发布,它用组件和状态整理复杂界面,但它主要解决“界面怎样写”,并不替项目决定路由、服务器渲染、数据更新和生产构建。
Next.js 在 2016 年发布,把 React 项目里经常重复的工程选择整理成约定。文件可以成为路由,页面可以在服务器上生成,也可以只把需要交互的组件交给浏览器。App Router 从 Next.js 13 开始进入主线,并在 13.4 稳定;我们使用的 Next.js 16 延续这套模型,默认以服务器组件组织页面,再在明确需要交互的位置建立客户端边界。
你可以先把三者的职责记成下面这样:
先不写代码,试着读下面三个地址,并说出每个地址想取得什么资源:
/
/books
/books/the-long-river/ 是首页,/books 是书架列表,/books/the-long-river 是一本书的详情。后面我们会让这三个地址分别对应 src/app/page.tsx、src/app/books/page.tsx 和 src/app/books/[slug]/page.tsx。
如果你已经能把“React 组件”和“页面地址”分开,第一层认识就够了。React 决定页面里有哪些组件,Next.js 决定一次地址请求怎样找到页面、怎样取得数据,以及哪些代码需要送进浏览器。
Next.js 并没有替代浏览器、HTML 或 React。它把这些技术放进一套可运行、可构建的项目约定里。后面遇到新术语时,先判断它属于浏览器、React 还是 Next.js,理解会容易很多。
latest 会随时间变化。同一句安装命令,几个月后可能生成不同的目录或启用不同的默认选项。教程如果只写“使用最新版”,读者很难判断报错来自自己的操作,还是来自版本变化。
这套项目已经按下面的基线验证。创建命令固定 create-next-app,项目依赖再由 package-lock.json 锁定到可复现的安装结果。
Next.js 16 的最低 Node.js 版本是 20.9,但“最低可运行”不等于适合作为新项目基线。Node.js 24 是当前长期支持版本,所以课程统一使用它。
Node.js 官网的下载页会根据操作系统提供安装包。选择标有 LTS 的 Node.js 24:macOS 下载 .pkg,Windows 下载 .msi,按安装向导保留默认选项即可。Linux 可以在同一下载页选择官方提供的版本管理器或二进制包。
安装结束后再打开一个新的终端窗口。下面两个命令都只读取版本,不会修改项目:第一个确认 Node.js,第二个确认随 Node.js 安装的 npm。
node --version
npm --version第一行应以 v24. 开头,第二行会显示 npm 的版本号,例如:
v24.x.x
11.x.x补丁版本会随 Node.js 24 的维护更新而变化,所以这里用 x 表示任意维护版本。判断标准是主版本为 24,且两个命令都没有出现“找不到命令”。
如果终端仍然找不到 node,先关闭旧终端再重新打开,让安装程序写入的新路径生效。不要急着全局安装 next;项目会自己保存 Next.js 版本,全局安装反而容易让命令来自错误版本。
npx 会下载并运行指定版本的命令行工具。我们显式写出 create-next-app@16.2.10,避免生成器自动跳到未来版本。其余选项决定项目骨架:
--ts 使用 TypeScript。--tailwind 配置 Tailwind CSS 4。--eslint 配置 ESLint。--app 使用 App Router。--src-dir 把应用代码放进 src/。--import-alias "@/*" 让 @/ 指向 src/。--use-npm 使用 npm 并生成 package-lock.json。--yes 接受已经明确或默认的选项,不再逐项提问。命令会创建名为 shiguang-library 的新目录,并在其中下载依赖。确认当前目录没有同名文件夹,再执行:
npx create-next-app@16.2.10 shiguang-library --ts --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm --yes这条命令可以拆成一条完整的执行链:npx 先取得 16.2.10 版生成器,生成器创建目录与初始文件,接着 npm 根据模板的 package.json 安装依赖,最后写出 package-lock.json。因此,终端显示“创建文件”和“安装包”是同一次操作的两个阶段;只有看到成功提示并重新出现命令提示符,创建过程才算结束。
固定生成器版本解决的是“项目骨架从哪一版模板产生”,锁文件解决的是“这一批直接和间接依赖怎样还原”。二者职责不同,不能只固定前者就删除锁文件。命令还必须在 shiguang-library 的父目录执行:如果已经存在同名且非空的目录,生成器会拒绝覆盖,先确认其中是否有需要保留的内容,不要直接强行删除。
如果安装阶段因网络中断而失败,先阅读错误发生在“下载生成器”还是“安装项目依赖”。前者通常还没有生成完整目录;后者可能留下半成品。确认目录里没有自己的文件后,再移除半成品并重新执行完整命令,比在未知状态下继续补装更容易得到可复现结果。
命令完成后,shiguang-library 目录已经存在。进入目录,并让 npm 读取项目记录的三个核心版本:
cd shiguang-library
npm pkg get dependencies.next dependencies.react dependencies.react-domcd 会把当前工作目录切换到新项目,后续所有相对路径和 npm 命令都以这里为起点。npm pkg get 只读取当前 package.json,三个参数分别指向 dependencies.next、dependencies.react 与 dependencies.react-dom;它不会启动服务器,也不会修改依赖。如果这里提示找不到 package.json,最常见的原因是没有成功进入项目目录。
结果应包含这三个值:
{
"dependencies.next": "16.2.10",
"dependencies.react": "19.2.4",
"dependencies.react-dom": "19.2.4"
}这是 create-next-app@16.2.10 自身固定的可复现模板版本,不代表 React 当前没有更新。Next.js 的 peer dependency 允许 React 19.2.7;为了让核心运行栈也落在当前稳定补丁上,紧接着成对升级 React 与 React DOM,并要求 npm 保存精确版本:
npm install --save-exact react@19.2.7 react-dom@19.2.7npm install 会完成三件事:把新包放进 node_modules,更新 package.json 的直接依赖版本,并重算 package-lock.json。--save-exact 让清单保存精确的 19.2.7,而不是允许未来自动漂移到其他次版本的范围。React 与 React DOM 是配套的渲染核心,所以这里成对升级,避免一边已经更新、另一边仍停在模板补丁版本。
安装日志中的普通提示不等于失败。判断标准是命令退出时没有 npm ERR!,随后版本读取结果正确。不要只改 package.json 文本却跳过安装;那样清单、锁文件与 node_modules 会彼此不一致。
再次读取版本:
npm pkg get dependencies.next dependencies.react dependencies.react-dom最终应得到:
{
"dependencies.next": "16.2.10",
"dependencies.react": "19.2.7",
"dependencies.react-dom": "19.2.7"
}package-lock.json 记录了直接依赖和间接依赖的精确安装结果。后续重新安装时使用同一份锁文件,npm 才能还原这次已经验证的依赖组合。
Next.js 不要求我们手写一张路由表。它读取约定好的目录和文件名:page.tsx 表示页面,layout.tsx 表示共享布局,globals.css 保存全局样式。文件放在正确位置,框架就知道怎样使用它。
create-next-app 还生成配置、类型和工具文件。我们先认清入口,不需要一次读懂每个配置项。
打开刚创建的 shiguang-library,先关注下面这些文件。课程后面的目录会继续增长,但这些入口会一直保留。
shiguang-library/
├── public/
├── src/
│ └── app/
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── package-lock.json
├── postcss.config.mjs
└── tsconfig.json你此时不需要修改配置文件。只要 src/app/page.tsx 和 src/app/layout.tsx 存在,App Router 就已经有了首页和根布局。
先打开 src/app/page.tsx。16.2.10 版 Tailwind 模板生成的内容如下;它虽然比“Hello World”长,但仍然只是一个没有状态的 React 组件:
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width=
第一行导入 Next.js 的 Image 组件,两个图片地址都以 / 开头,所以会读取 public/next.svg 与 public/vercel.svg。export default 把 Home 交给当前路由;因为文件位于 app 根目录,它的返回值就是 / 的页面内容。函数没有参数、状态和事件,输入是固定模块内容,输出是一棵 JSX 元素树。
JSX 看起来像 HTML,但有几处关键差别:样式写作 className,数字属性用花括号传入,{" "} 显式保留单词之间的空格。sm: 与 dark: 是 Tailwind 的条件前缀,分别表示较宽视口和深色模式。外部链接使用普通 a,target="_blank" 打开新标签,rel="noopener noreferrer" 防止新页面取得原页面的控制引用。
这段代码的运行流是:App Router 匹配 / → 执行 Home → Image 处理静态图片尺寸与加载 → Tailwind 类决定布局 → 结果进入根布局。常见错误包括把 className 写成 class、把 public 写进 src、图片路径写成 /public/next.svg,或者删除默认导出。我们不会继续扩展这张模板页,第 3 课会用自己的首页完整替换它。
再打开 src/app/layout.tsx:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
根布局的输入是 children,也就是当前 Page 的渲染结果;输出则是完整文档的 html、body 与页面内容。Metadata 是类型导入,metadata 会由 Next.js 转成标题和描述标签。next/font 准备字体并返回 CSS 变量类,模板字符串把两个变量类挂到 html。import "./globals.css" 没有接收返回值,它的职责是让全局样式进入应用。
根布局必须默认导出,并且根级布局必须保留 html 与 body。删除 {children} 会让 Page 虽然被匹配,却没有插入页面的位置;把浏览器 API或点击状态放进这里,则会无谓扩大后续客户端代码边界。第 2 课会把默认英文 Metadata 和语言标记换成项目自己的内容。
package.json 中的脚本是 npm 为常用命令保存的别名:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
}
}npm run dev 用于边写边看,npm run build 生成生产结果,npm run start 运行已经构建的结果,npm run lint 检查代码规则。npm 会优先寻找项目 node_modules/.bin 中的命令,所以这些脚本使用的是项目锁定的 Next.js 与 ESLint,不依赖全局安装。不要把这段对象单独覆盖整个 package.json;这里只是在阅读生成器已经写好的 scripts 属性。
package.json 的 scripts 把常用命令起了固定名字。npm run dev 实际执行 next dev,它会启动开发服务器,并由 Turbopack 按需编译当前访问的页面。保存源文件后,浏览器可以很快看到变化,不需要每次重新构建整个项目。
开发服务器启动后会一直占用当前终端。这个状态是正常的;需要停止时按 Ctrl + C。
确认终端当前位于 shiguang-library,再运行:
npm run dev终端会显示框架版本、访问地址和就绪状态。关键内容应与下面一致,启动耗时可能不同:
▲ Next.js 16.2.10 (Turbopack)
- Local: http://localhost:3000
✓ Ready在浏览器打开 http://localhost:3000。
浏览器会显示 create-next-app 提供的初始页面。回到终端,还会看到一次 GET / 请求;它表示浏览器请求了首页,Next.js 找到 src/app/page.tsx 并返回结果。
下面是这一阶段在 http://localhost:3000/ 的真实结果。截图使用 1440×900 视口,在开发服务器显示 Ready 后首次打开首页;请重点核对左上方 Next.js 标志、中央引导文案、两个外部入口,以及页面是否完整占据视口。

可以用下面这条链路检查自己是否真的理解了第一次运行:
浏览器访问 /,开发服务器收到一次首页请求。这个地址没有额外路径段,所以 App Router 会寻找 src/app/page.tsx。
Next.js 编译页面和根布局,把可显示的结果返回给浏览器。第一次访问需要编译,后续修改只更新受影响的模块。
浏览器显示初始页面,终端记录 GET /。页面和请求记录同时出现,说明 Node.js、依赖、路由入口和开发脚本已经连通。
这一阶段的验收结果很具体:Node.js 主版本是 24,项目依赖中的 Next.js 是 16.2.10,npm run dev 显示 Ready,浏览器能够打开 /。下一篇会从这条 / 请求出发,把目录扩展成真正的页面地图。
请先判断下面的问题,再查看反馈。题目只检查本篇真正用过的知识。
练习:不用看上文,试着解释浏览器访问 / 后,Next.js 为什么会找到 src/app/page.tsx。