拾光书架已经能读数据、筛选、写入并刷新当前路由,也有元数据和测试。最后一课不再添加页面功能,我们要回答三个发布问题:代码是否通过一致的质量门槛,Next.js 16.2.10 实际生成了什么,运行平台是否符合这个项目的存储模型。
开发服务器容忍快速修改,生产构建则要决定哪些页面提前生成、哪些页面按请求渲染,并追踪运行时真正需要的文件。部署不是把源代码随便复制到一台服务器;它是把构建产物、静态资源、配置、持久化数据和运维规则组成一个可重复运行的版本。
本课结束时,你会完成 lint、类型检查、测试和构建四道门,读懂 ○、◐、ƒ 三种路由标记,启动 standalone 产物,得到可直接使用的 Dockerfile,并根据文件写入边界选择部署方式。

四条命令检查不同问题,不能因为 next build 成功就删掉其他三条:
Next.js 16 已经移除 next lint,而且 next build 不再自动运行 linter,所以 lint 脚本必须直接调用 ESLint。类型检查虽然也会出现在构建流程中,单独运行 tsc --noEmit 反馈更快,也让持续集成明确知道失败发生在哪一层。
四道门的顺序从快到慢排列。前一门失败就先修复,不必带着已知错误等待完整构建。
如果第 9 课的开发服务器仍在运行,先在对应终端按 Ctrl+C 停止它,避免开发进程与构建进程同时写入 .next。然后确认 package.json 的脚本如下:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}scripts 把团队约定收进 package.json:每个人与持续集成都通过同一个名称调用工具。tsc --noEmit 只检查类型,不生成额外 JavaScript;vitest run 执行一次后退出,适合门禁;eslint 没有文件参数时按当前项目配置检查目标文件。
npm ci 的输入是 package-lock.json,它会重建依赖目录并拒绝 package 与 lockfile 不一致的状态;npm install 则可以重新解析并更新锁文件。因此安装依赖或调整 override 时用 install,复现已经确认的依赖树时用 ci。常见错误是手改 package.json 后忘记提交对应 lockfile,直到 CI 才发现无法复现。
按顺序执行:
npm run lint
npm run typecheck
npm run test
npm run build持续集成中也应使用这四条命令。npm ci 根据锁文件安装完全一致的依赖,适合干净构建:
npm ci
npm run lint
npm run typecheck
npm run test
npm run build不要用 npm run build 代替 lint。Next.js 16 的生产构建不会替你运行 ESLint,一条没有被调用的 lint 脚本等于没有门禁。
当前项目的四道门结果是:
lint 通过,0 个错误
typecheck 通过,0 个类型错误
test 通过,1 个测试文件、2 条测试
build 通过,17/17 个静态生成任务完成测试阶段会明确显示:
Test Files 1 passed (1)
Tests 2 passed (2)这份结果只证明当前提交通过了现有规则。以后新增支付、登录或更复杂的数据写入时,还要为新风险增加相应测试,而不是把“曾经通过”当成永久保证。
next build 不只把 TypeScript 变成 JavaScript。它会分析路由树、缓存指令、请求时数据和 Suspense 边界,决定每个路由怎样交付。
Next.js 16.2.10 的输出中,本项目会看到三种符号:
○ Static:构建时预渲染成静态内容。◐ Partial Prerender:静态外壳提前生成,请求相关部分在 Suspense 边界中流式补充。ƒ Dynamic:收到请求时由服务器渲染或执行。Revalidate 与 Expire 来自缓存寿命。数据函数使用 cacheLife("hours") 后,构建表会显示一小时重新验证、一天过期。它们描述缓存策略,不是“页面每小时必定被访问一次”。
执行 npm run build 后,找到输出末尾的路由表。当前项目的关键部分如下:
Route (app) Revalidate Expire
┌ ◐ / 1h 1d
├ ○ /_not-found
├ ○ /about
├ ƒ /api/books
├ ◐ /books
├ ◐ /books/[slug] 1h 1d
│ ├ /books/[slug] 1h 1d
│ ├ /books/the-long-river 1h 1d
│ ├ /books/slow-observation 1h 1d
│ └ [+4 more paths]
├ ◐ /notes 1h 1d
├ ○ /robots.txt
└ ○ /sitemap.xml 1h 1d
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand逐行对照源码:
/books 读取异步 searchParams,但读取发生在 Suspense 内,所以页面保留静态外壳并显示 ◐。
/api/books 是按请求读取查询参数的 Route Handler,所以显示 ƒ。
六本书由 generateStaticParams 提供,详情路由列出已知路径;动态边界仍使该路由使用部分预渲染。
构建成功时,终端先显示 Turbopack 编译、TypeScript、页面数据收集和静态生成,然后出现上面的路由表。当前版本不会再显示旧教程常见的 “First Load JS” 数字;Next.js 16 移除了这组容易误导的构建指标。
普通 next start 依赖完整项目和 node_modules。output: "standalone" 会启用输出文件追踪,把生产服务器真正需要的模块复制到 .next/standalone,并生成一个最小 server.js。它适合容器或自管 Node.js 服务。
standalone 不会自动复制两个静态目录:
public:项目直接公开的文件。.next/static:构建生成的 JavaScript、CSS 和字体。生产平台若使用 CDN,可以单独发布它们;如果希望 server.js 自己提供静态资源,就要复制进 standalone 的对应位置。缺少这一步时,HTML 也许能返回,但页面脚本、样式或字体会出现 404。
当前数据层根据 process.cwd()/data 读取和写入 JSON。启动时的工作目录必须包含 server.js 与 data,这也是下面先进入 standalone 目录的原因。
在 next.config.ts 保留 standalone 配置:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "media.edu-free.com",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;完成构建后复制静态文件,再启动最小服务器:
mkdir -p .next/standalone/public
cp -R public/. .next/standalone/public/
mkdir -p .next/standalone/.next/static
cp -R .next/static/. .next/standalone/.next/static/
mkdir -p .next/standalone/data
cp -R data/. .next/standalone/data/
cd .next/standalone
HOSTNAME=0.0.0.0 PORT=3000 node server.js前两行先创建 standalone 的 public,再复制其中内容;接下来两组命令用相同步骤放入 .next/static 与可写的 data。显式复制 data 比依赖输出追踪更稳妥,也让后续命名卷有明确挂载目标。进入运行目录后,process.cwd() 才会与 server.js、data 对齐;HOSTNAME=0.0.0.0 允许容器端口映射访问,PORT 指定监听端口。
执行 cd 之前,可以运行 test -f .next/standalone/data/books.json 与 test -f .next/standalone/data/notes.json,确认两次复制都成功。若数据模型后来改为运行期外部目录,应显式挂载或复制。不要把整个源码目录复制进 standalone 来“修好”缺失资源,那会失去最小产物的意义。
常见故障可以按输出判断:HTML 有内容但无样式,通常漏了 .next/static;/file.svg 为 404,通常漏了 public;页面读取报 ENOENT,检查工作目录与 data。三种问题不是同一个修复方向。
正确的运行目录至少包含:
.next/standalone/
├── server.js
├── package.json
├── data/
│ ├── books.json
│ └── notes.json
├── node_modules/
├── public/
└── .next/
├── server/
└── static/启动后检查四个结果:
200,样式与字体正常。/file.svg 返回 200,证明 public 已复制。/_next/static/... 返回 200,证明构建静态资源已复制。/api/books?q=城市&status=finished 返回一条《雨后博物馆》。检查完成后回到运行服务器的终端按 Ctrl+C。此时终端仍位于 .next/standalone,继续执行 cd ../.. 返回项目根目录;用 pwd 与 test -f package.json 确认位置,再进入 Dockerfile 实操。否则下一条 docker build . 会把错误的目录当作构建上下文,残留服务器也会占用 3000 端口。
容器的价值是把 Node.js 版本、依赖安装、构建命令、运行用户和启动方式写成可重复的文件。多阶段构建把工作分成三层:
deps:根据 lockfile 安装依赖。builder:复制源码并执行生产构建。runner:只保留 standalone、静态资源和数据,不携带完整开发依赖。Next.js 16 要求 Node.js 20.9 或更高版本。这里继续使用课程统一的 Node.js 24 LTS,并选择体积较小的 Alpine 镜像;生产进程使用非 root 用户。
NEXT_PUBLIC_SITE_URL 是公开的构建输入。当前 sitemap 会在 next build 时生成,Next.js 也会把 NEXT_PUBLIC_ 值固定进构建产物,所以必须通过构建参数提供真实域名。镜像完成后再给 docker run 传同名变量,不能重写已经生成的 sitemap;域名改变时应重新构建镜像。任何带 NEXT_PUBLIC_ 前缀的值都可能公开,不能放密码。
在项目根目录创建 Dockerfile:
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:24-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV
创建 .dockerignore,避免把已有构建产物和依赖发送给构建器:
node_modules
.next
.git
.gitignore
.env*
npm-debug.log*
Dockerfile*
README.md构建并运行:
docker build \
--build-arg NEXT_PUBLIC_SITE_URL=https://books.example.com \
--tag shiguang-library:1.0.0 \
.
docker run \
--detach \
--name shiguang-library-first \
--publish 3000:3000 \
--volume shiguang-data:/app/data \
shiguang-library:1.0.0三个阶段各有明确输出。deps 只根据 package 与 lockfile 安装依赖,Docker 可以在源码变化时复用这一层;builder 接收公开站点地址、复制源码并生成 standalone;runner 只复制运行服务器、静态资源和 data,不携带完整源码与开发依赖。
ARG 的默认值保证未传参数时仍是合法 URL,正式构建再用 --build-arg 覆盖。四条 COPY --chown 让非 root 的 nextjs 用户既能读取资源,也能写 data/notes.json。若遗漏 data 的所有权,页面读取可能正常,第一次提交却会因权限失败。CMD 使用 JSON 数组的 exec 形式,使 Node 直接接收停止信号。
.dockerignore 的输入是构建上下文,输出是发送给 Docker daemon 的文件集合。排除依赖、构建产物、Git 元数据、环境文件和 Dockerfile,可以减少上下文并避免把不需要的配置带进镜像。
运行命令中的 --detach 让容器在后台启动;--publish 把主机 3000 映射到容器 3000;--volume 把命名卷挂到可写 data。站点地址已经由 docker build --build-arg 固定,运行命令无需再传同名变量。命名卷让 /app/data 脱离容器生命周期。第 6 课特意让 getNotes() 保持请求时读取,因此重建后能看到卷的当前内容。
最终运行镜像不需要再次执行 npm install,启动命令只有:
node server.js容器以 nextjs 用户监听 3000 端口,public 和 .next/static 都在正确位置,data 使用命名卷。提交一条书摘、重启同名卷挂载的新容器后,书摘仍然存在。
先在第一个容器的 /notes 提交一条容易辨认的书摘,然后真正删除容器并用同一命名卷创建第二个容器:
docker rm --force shiguang-library-first
docker run \
--detach \
--name shiguang-library-second \
--publish 3000:3000 \
--volume shiguang-data:/app/data \
shiguang-library:1.0.0删除第一个容器会移除它的可写层,却不会删除显式命名的 shiguang-data。第二次运行把同一个卷重新挂到 /app/data,因此 getNotes() 读到先前写入,而不是镜像中的初始文件。若第二个容器看不到记录,检查两条命令的卷名和挂载路径是否完全一致。
下面是真实的第二容器页面。URL 为 http://localhost:3000/notes,视口为 1440×900;等待第二容器启动后刷新共读墙并捕获。观察先前提交的“容器持久化验收”记录仍在,且页面样式、字体与表单均正常,说明 standalone 静态资源和命名卷同时生效。

挂载持久卷只能解决“容器重建后文件还在”,不能解决多个副本并发写同一份 JSON。需要横向扩容时,应先把书摘迁移到支持事务的数据库。
Next.js 可以作为 Node.js 服务、Docker 容器、平台托管应用或静态导出部署,但并非每种方式都适合当前业务。
拾光书架使用 Server Action、运行时文件写入,以及读取 Request 查询参数的 Route Handler,因此不能用纯静态导出。静态导出并非一概排斥 Route Handler:不依赖请求、可以在构建期确定结果的 GET Handler 可以生成静态文件;本项目的 /api/books 和写入链路不满足这个条件。运行时写入还要求一个可持久化、可写的 data 目录。许多无服务器平台的函数文件系统是只读或短暂的;即使某次写入成功,下次请求也未必落在同一个实例。
部署选择必须从数据一致性倒推:
为项目做一次发布决策:
如果目标是展示课程成果,选择单副本容器与持久卷,并明确它不是多人高并发架构。
如果目标是公开运营,先把 getNotes 与 createNote 迁移到数据库,再补认证、授权、限流和备份策略。
如果要运行多个 Next.js 实例,除了共享数据库,还要评估共享缓存、标签失效协调和 Server Action 跨实例配置。
无论选择哪种平台,都要在生产构建开始前设置真实 NEXT_PUBLIC_SITE_URL,并让平台健康检查访问一个稳定的只读路由。
当前课程项目的合理发布结论是:
学习展示:
单副本 Node.js / Docker + 持久 data 目录
正式多人使用:
托管 Next.js / 多副本容器
+ 事务数据库
+ 共享缓存协调
+ 身份与权限发布成功不是终点。运行时至少要回答:进程是否活着、请求是否成功、错误发生在哪个入口、部署的是哪个版本。
Next.js 服务器把启动信息和未处理错误写到标准输出、标准错误。容器平台可以统一收集这两条流。浏览器错误边界中的 console.error(error) 只帮助观察客户端边界;Server Action 或 Route Handler 的服务器异常仍要看服务端日志。
日志应记录路由、状态、耗时、版本和不含敏感内容的错误摘要。不要记录整份 FormData、用户私密书摘、Cookie、Authorization 头或环境变量。面对生产错误,digest 可以帮助关联用户看到的错误与服务器记录,但不能把堆栈返回给用户。
容器运行后查看最近日志并持续跟随:
docker logs --tail 100 --follow shiguang-library-second用稳定的只读入口做基础健康检查:
curl --fail --silent --show-error \
"http://127.0.0.1:3000/api/books?status=finished"发布平台的探针可以检查 / 或 /api/books?status=finished 是否返回 2xx。健康检查不要创建书摘,否则每次探测都会修改业务数据。
记录每次发布的镜像标签和 Git 提交,例如:
image: shiguang-library:1.0.0
commit: 4f82c1a
startedAt: 2026-07-16T03:00:00Z一次健康的发布应呈现:
200 和可解析 JSON。noindex;流式响应可能保持 HTTP 200,不能把它误报为进程健康故障。如果容器不断重启,先看启动日志和退出码;如果 HTML 正常但没有样式,检查 .next/static;如果新增书摘重启后消失,检查数据卷挂载。不同现象对应不同层次,不要一开始就归因于“Next.js 坏了”。
框架会继续发布补丁和新版本。升级不是删掉 lockfile、安装一堆最新版再观察页面能否打开,而是一次有输入、有差异、有验证、有回退点的代码改动。
当前项目明确锁定 Next.js 16.2.10、React 19.2.7、React DOM 19.2.7 和 eslint-config-next 16.2.10。Next.js 与它的 ESLint 配置应保持同一版本线,React 与 React DOM 也应成对升级。
Next.js 16 的几个基线要记住:
next dev 与 next build 默认使用 Turbopack。params、searchParams 等请求 API 必须异步读取。next lint 已移除,使用 ESLint CLI。版本号看起来正确,也不能跳过依赖审计。npm audit 检查 lockfile 中真正安装的依赖树,包括框架带入的间接依赖。发现问题后要先确认受影响包、调用路径和修复版本,再决定升级顶层依赖还是使用受控 overrides。
先审计当前锁定的依赖:
npm audit --registry=https://registry.npmjs.org
npm ls postcss部分 npm 镜像没有实现 audit API,会返回 404 NOT_IMPLEMENTED。上面的 --registry 只让这一次审计查询官方 npm registry,不会改写项目的长期镜像配置;若当前 registry 已支持 audit,也可以省略它。
审计发现 Next.js 16.2.10 的内部依赖仍解析到 postcss@8.4.31,对应当前中危公告。修复版本 8.5.19 与项目使用的主版本一致,因此在 package.json 添加全局覆盖:
{
"overrides": {
"postcss": "8.5.19"
}
}重新安装以更新 lockfile,然后确认解析结果并再次审计:
npm install
npm ls postcss
npm audit --omit=dev --registry=https://registry.npmjs.org
npm ci --dry-run覆盖是当前上游依赖尚未更新时的明确过渡措施。要在后续 Next.js 版本吸收修复后重新评估并移除,避免长期固定一个不再需要的间接版本。
再查看可升级项和发布说明,并在独立分支运行官方升级工具:
npm outdated
git switch -c chore/next-upgrade
npx @next/codemod@canary upgrade latest如果要跨主版本,显式指定目标版本,并先阅读对应迁移指南。升级工具完成后检查 package.json、package-lock.json 和源码差异,然后重新执行四道门:
npm run lint
npm run typecheck
npm run test
npm run build最后重新验证 standalone 启动、静态资源、搜索 URL、Server Action 和 metadata。框架升级影响的不只编译器,运行产物也要复查。
不要执行 npm audit fix --force 后直接发布。--force 可能跨主版本替换框架或绕开 peer dependency 约束;安全修复仍要审阅依赖差异,并通过完整门禁与关键流程回归。
这次受控覆盖的具体结果是:npm install 安装 395 个包并审计 396 个包,最终显示 found 0 vulnerabilities;npm audit --omit=dev 同样显示 found 0 vulnerabilities,npm ci --dry-run 显示依赖已是最新锁定状态。
一次合格升级应留下这些证据:
依赖差异 已审阅
postcss 统一解析到 8.5.19
生产依赖审计 0 vulnerabilities
锁文件复现 npm ci --dry-run 通过
官方迁移项 已处理
lint 通过
typecheck 通过
test 通过
build 通过
standalone 启动通过
关键流程 回归通过如果审计仍报告问题,或任意门失败,升级仍在进行中。不要用 --force 掩盖 peer dependency 冲突;先弄清 Next、React、React DOM 和类型包要求的组合。
最终验收要从用户行为走到运行产物,避免“每个文件单独看都没问题,连起来却失败”。清理则要区分可再生内容和持久数据:
.next、测试输出、停止的课程容器和未使用镜像可以重新生成。src、public、配置、lockfile 与课程数据属于项目内容。清理的目的,是让下一次安装、构建和发布仍能从仓库完整复现,而不是把关键文件一起删掉。
按下面顺序完成最终验收:
从干净依赖安装开始,通过 lint、typecheck、test、build 四道门。
启动 standalone,检查首页、书架、详情、共读墙、404、错误边界、robots、sitemap 和 JSON 接口。
使用 GET 表单生成可分享筛选 URL,确认页面结果与 /api/books 数量一致。
分别提交无效与有效书摘,确认字段错误、pending、成功消息、列表刷新和重启后的持久性。
验收结束后,先停止正在运行的进程。若使用课程容器,可清理容器与镜像:
docker rm --force shiguang-library-first shiguang-library-second \
2>/dev/null || true
docker image rm shiguang-library:1.0.0构建目录可以删除,需要时由 npm run build 重建:
rm -rf .next只有确认示例书摘不再需要时,才删除命名卷:
docker volume rm shiguang-data数据卷删除不可逆。正式数据应先验证备份可以恢复;“容器已经删除”不是删除数据卷的理由。
最终项目具备一条从开发到发布的完整链路:
页面与组件
→ Server Component / Client Component 边界
→ 缓存读取与可分享搜索
→ Server Action 校验、写入并返回成功状态
→ Client Component 调用 router.refresh()
→ metadata、JSON-LD、robots、sitemap
→ lint、typecheck、test、build
→ standalone / Docker
→ 日志、健康检查、升级与清理最后做一次判断:如果明天要让一百位读者同时发布书摘,第一项架构改动应该是什么?
到这里,我们不是只完成了一组 Next.js 页面,而是走完了一个项目的真实生命周期:从 URL 与数据边界,到写入一致性,再到构建产物和部署约束。以后换成别的业务,页面内容会变,但这套判断顺序仍然适用。
/ 的书摘计数和 /notes 的列表都在 Suspense 中请求时读取可写文件,因此两条路由显示 ◐;书籍数据仍使用 hours 缓存,所以构建表保留 1h / 1d。
/sitemap.xml 在构建时读取缓存书目并生成静态响应,所以显示 ○,同时继承书籍数据的 1h / 1d 缓存策略;它不是按请求运行的 ƒ 路由。
检查宽屏与窄屏真实截图,确认 metadata、JSON-LD、字体和远程图片。