CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
综合练习与部署
这一课你将能做到
独立完成一个端到端小功能:「学习笔记」——动态路由 + Server 取数 + Server Action 写入 + 门控,把 12 课知识串成一条线
跑通 npm run build 并读懂路由输出表,修掉构建期暴露的类型与环境问题
完成一次真实部署(Vercel 或 Node 服务器 + Nginx),并掌握上线前检查清单
为什么要学
课程学到这里,你已经有了完整的技术地图:路由、RSC、取数缓存、渲染模式、接口、MDX、样式、认证,还走读过生产仓库。但「知道」和「做出来」之间隔着一个构建与部署的深渊——大量项目死在最后一公里:npm run build 时类型报错、部署后环境变量丢失、页面白屏、后端跨域 403。本课把最后这一公里走完。
综合练习的题目刻意选得小而全:「学习笔记」功能 200 行代码以内,却同时用到动态路由(第 3 课)、Server 取数(第 5 课)、Server Action 写入、门控(第 10 课)、Tailwind(第 9 课)。做完它,你就有了一个能写进简历、能当面演示、能被面试官追问细节的完整作品。
部署的意义再强调一次:面试官对「本地能跑」和「线上能访问」的项目,评估完全不同。一个有公网 URL 的作品集,胜过十份「代码在我电脑上」的简历。
先建立图像
本课的完整交付物架构:
┌─ 你的 Next.js 项目(生产构建产物)
│ /notes 列表页(Server:读当前用户笔记)
│ /notes/new 新建页(Server Action 提交)
│ /notes/[id] 详情页(动态段)
│ /api/auth/* 登录/登出 BFF(第 10 课代码复用)
└─ 数据来源(三选一,练习难度递增)
① 本地 JSON 文件(fs 读写,纯前端自洽)
② 内存 Map(重启即失,最快验证)
③ Spring Boot REST(真实架构,对接第 5 课封装)
部署形态对比:
Vercel:git push 即部署,免费额度,零运维 ← 练习推荐
Node 服务器:npm run build && npm run start,Nginx 反代 ← 企业常见
Docker 容器:环境一致,本站 platform 就是 Dockerfile 部署
上线前后行为差异速查(踩坑高发区):
环节 dev 行为 生产行为 环境变量 读 .env.local 读部署平台配置(构建时注入) 未捕获错误 红屏详细堆栈 白屏/error.tsx,细节进日志 路由渲染 一律动态、即时生效 按 build 判定,静态页冻结 localhost 指向你的机器 指向服务器自己,注意 API 地址
跟着做
步骤 1:搭建笔记模块骨架
src/app/notes/
├── page.tsx # 列表
├── new/page.tsx # 新建
├── [id]/page.tsx # 详情
└── actions.ts # Server Action(写操作)
数据层用内存 Map(步骤最简,真实项目换成 fetch Spring Boot):
// src/lib/notes.ts
export interface Note {
id: string;
title: string;
content: string;
createdAt: string;
}
const notes = new Map<string, Note>();
export async function listNotes(): Promise<Note[]> {
return [...notes.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function getNote(id: string): Promise<Note | null> {
return notes.get(id) ?? null;
}
export async function createNote(title: string, content: string): Promise<Note> {
const note: Note = {
id: crypto.randomUUID(),
title,
content,
createdAt: new Date().toISOString(),
};
notes.set(note.id, note);
return note;
}
步骤 2:列表页与详情页(Server 组件) // src/app/notes/page.tsx
import Link from "next/link";
import { listNotes } from "@/lib/notes";
export const dynamic = "force-dynamic"; // 数据在内存里,必须动态
export default async function NotesPage() {
const notes = await listNotes();
return (
<main className="mx-auto max-w-2xl p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">我的学习笔记</h1>
<Link className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white" href="/notes/new">
新建
</Link>
</div>
<ul className="mt-4 space-y-2">
{notes.map((n) => (
<li key={n.id} className="rounded-lg border p-3 hover:bg-gray-50">
<Link href={`/notes/${n.id}`} className="font-medium">
{n.title}
</Link>
<p className="mt-1 text-xs text-gray-500">{n.createdAt}</p>
</li>
))}
{notes.length === 0 && <p className="text-sm text-gray-500">还没有笔记。</p>}
</ul>
</main>
);
}
// src/app/notes/[id]/page.tsx
import { notFound } from "next/navigation";
import { getNote } from "@/lib/notes";
export default async function NotePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const note = await getNote(id);
if (!note) notFound();
return (
<article className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-bold">{note!.title}</h1>
<time className="mt-1 block text-xs text-gray-500">{note!.createdAt}</time>
<div className="mt-4 whitespace-pre-wrap">{note!.content}</div>
</article>
);
}
步骤 3:Server Action 写入(本课新知识点) // src/app/notes/actions.ts
"use server"; // 文件级声明:所有导出函数都是 Server Action
import { redirect } from "next/navigation";
import { createNote } from "@/lib/notes";
export async function addNote(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
const content = String(formData.get("content") ?? "").trim();
if (!title) return { error: "标题不能为空" };
const note = await createNote(title, content);
redirect(`/notes/${note.id}`); // 成功后跳详情
}
// src/app/notes/new/page.tsx
import { addNote } from "../actions";
export default function NewNotePage() {
return (
<form action={addNote} className="mx-auto max-w-2xl space-y-3 p-6">
<h1 className="text-2xl font-bold">新建笔记</h1>
<input name="title" placeholder="标题" className="w-full rounded border p-2" />
<textarea name="content" rows={8} placeholder="内容" className="w-full rounded border p-2" />
<button className="rounded bg-blue-600 px-4 py-2 text-white">保存</button>
</form>
);
}
看这个 <form action={addNote}>:没有 onSubmit、没有 fetch、没有 'use client' ——表单直接绑定服务端函数,提交时框架自动序列化 FormData 并在服务器执行。这就是 Server Action 的威力(渐进增强:JS 未加载完也能提交)。校验失败时返回 { error } 配合 useActionState 可在客户端展示,进阶自己扩展。
步骤 4:构建体检 Route (app) Size Revalidate
○ / 5 kB ─
ƒ /api/login 0 B ─ ← 登录 BFF,动态
ƒ /notes … ─ ← force-dynamic
ƒ /notes/[id] … ─
● /notes/new … ─ ← 纯表单页,静态
Type error: Property 'x' does not exist:TS 类型问题,build 比 dev 严格,按提示补类型
Error occurred prerendering page:静态页在构建时被渲染了,但里面有服务端专用调用(如读环境变量缺失的接口)——给该页加 dynamic = "force-dynamic" 或修数据层
内存 Map 的数据在静态页构建时被清空——注意我们给列表页标了动态,就是为了绕开「构建时快照」陷阱
步骤 5:部署到 Vercel(最快路径) npm i -g vercel
vercel # 首次会引导登录与项目关联
vercel --prod # 部署生产
或推到 GitHub 后在 Vercel 控制台导入仓库(推荐,之后 push 即自动部署)。部署完成后必须配置:
Vercel 控制台 → Settings → Environment Variables:
NEXT_PUBLIC_API_URL = https://你的后端域名 (浏览器要用,必须公网可达)
API_INTERNAL_URL = (如后端在别的平台,可留空回退公网)
配置后 Redeploy 一次才生效(环境变量是构建时注入的!)
对接真实 Spring Boot 后端时补两件事:后端给 Vercel 域名配 CORS 允许来源;或全部走 BFF 转发(服务端到服务端无 CORS 问题,本站选的就是后者)。
步骤 6:部署到自己的服务器(企业形态) npm run build
npm run start -- -p 3000 # 服务器上跑 3000 端口
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host; # Next 需要正确 Host
proxy_set_header X-Forwarded-Proto $scheme; # 让其知道外层是 http/https
proxy_set_header X-Forwarded-For $remote_addr;
}
}
进程守护用 pm2:pm2 start "npm run start" --name my-app。HTTPS 用 certbot 一条命令配 Let's Encrypt。本站 platform 的 Dockerfile 做的事同构:构建 → 生产依赖 → npm run start,理解了这一步就看懂了那个文件。
讲清楚要点
Server Action 补齐了「写操作」的拼图 :读用 async Server 组件,写用 "use server" 函数;表单直接 action={fn} 绑定,省掉整条「onSubmit → fetch → 处理响应」链路。它和 Route Handler 的分工在第 7 课已述:页面内变更用 Action,公网接口用 route.ts。
build 是第一道生产安检 :TS 全量类型检查、ESLint、静态页预渲染试跑,三关都在这里。dev 里藏的所有偷懒(any 满天飞、Server API 误用)都会在 build 暴露——所以「本地 build 一次再提交」是纪律。
环境变量是部署期问题,不是代码问题 :代码里 process.env.X 写对只是及格线,生产环境「值在哪配、何时注入、能否公网可达」才是重点。Vercel 改变量必须 Redeploy;服务器部署用启动环境变量或 .env.production。排查线上白屏第一步永远是「打开部署平台的环境变量页」。
动态与静态的最终判定以 build 输出为准 :你以为静态的页面可能因读了 cookies 变成 ƒ,你以为动态的页面可能被 Next 静态化。上线路前把路由表过一遍,和产品预期逐行核对。
上线检查清单(背下来) :① build 零报错 ② 环境变量配齐且密钥未用 NEXT_PUBLIC_ 前缀 ③ 路由表逐行核对渲染模式 ④ HTTPS 生效 ⑤ 后端 CORS/内网地址正确 ⑥ 错误页(not-found/error)存在 ⑦ 登录 Cookie 的 secure 在生产为 true ⑧ 移动端过一遍核心流程。
你可能会懵的地方
部署到 Vercel 后接口全 404 / 跨域报错?
分两查:Route Handler 的 404 → 确认 build 输出里有对应 ƒ 路由;后端请求报 CORS → 浏览器直连第三方域名被拦,把请求改走你的 BFF(route.ts 转发),服务端请求不存在 CORS。
内存 Map 重启数据就没了,生产怎么存?
练习用内存是为了聚焦流程。生产三选一:对接 Spring Boot(本课架构推荐)、托管数据库(Vercel Postgres/PlanetScale)、或 Serverless 环境下用带持久层的 BaaS。注意 Vercel 的 Serverless 函数没有常驻内存 ,Map 在生产必然失效——这本身就是个好的面试谈资。
为什么 dev 没问题,build 报一堆类型错?
dev 按需编译且容错高;build 跑全量 tsc。说明代码里一直有类型隐患,只是 dev 没走到那些分支。正确心态:build 报错是帮你排雷,逐个修完代码质量上一个台阶。
Server Action 安全吗?等于把后端逻辑暴露了?
Action 会编译成一个可被 POST 调用的端点,所以它就是公开接口 ,必须像对待 API 一样做鉴权与输入校验(本课笔记 Action 在真实项目里要先验登录态再写库)。误解「Action 是内部私有函数」是常见漏洞来源。
静态导出(output: "export")能不能部署我的项目?
只有全部页面纯静态(无 route.ts、无 Server Action、无动态读取)才行。有门控、有接口的项目必须 Node 服务器或 Vercel 全功能运行时。判断法:build 输出里有 ƒ 就别想纯静态托管。
错法对照 错法 问题 正解 改完代码不 build 直接部署 类型错/预渲染错在线上才炸 提交前本地 npm run build 过一遍 生产忘配环境变量或没 Redeploy 接口请求打到 localhost 全挂 部署平台配置 + 触发重新构建 Server Action 里不校验登录直接写库 任何人可调用的写接口 Action 内先 getServerToken 验证再操作 Vercel 上 NEXT_PUBLIC_API_URL 填内网地址 浏览器无法访问内网 公开变量公网地址,服务端专用走内网变量 Nginx 反代后重定向/表单异常 Next 拿不到真实 Host/协议 配 proxy_set_header Host 与 X-Forwarded-Proto
卡点急救
线上页面 500 :看部署平台日志(Vercel Logs / pm2 logs)。高频原因:环境变量缺失、后端不可达、Server Action 里抛异常。本课的 error.tsx 兜底 + 日志是唯一真相。
部署后样式丢失/类名没生效 :Tailwind 构建没扫到文件(检查扫描配置包含 src 全目录)或线上加载了旧缓存(强刷 + 检查资源版本号)。
Vercel 免费版函数冷启动慢 :首请求 1-2s 属正常。缓解:公开数据走静态/ISR 减少 Server 调用;对延迟敏感的把后端部署到同区域。
面试常问
你的 Next.js 项目是怎么部署的?上线前做了哪些检查?
参考回答:开发期本地 build 先过全量类型检查与预渲染安检;生产部署两条路径都实践过——Vercel(git push 自动构建,环境变量在平台配置、变更需 Redeploy)与自建服务器(npm run build + npm run start,pm2 守护、Nginx 反代并透传 Host/X-Forwarded 头、certbot 上 HTTPS)。上线清单:路由表核对渲染模式、环境变量齐备且密钥不分发到客户端、错误页兜底、Cookie secure、后端 CORS/内网直连验证。
Server Action 和传统表单提交相比改变了什么?要注意什么?
传统链路是 onSubmit 拦截 → fetch → 手动处理响应与重定向;Server Action 让表单 action 直接绑定服务端函数,框架负责序列化、调用与返回,天然渐进增强(无 JS 也可提交)。安全上它是公网可达的 POST 端点,必须做鉴权与输入校验;重定向用 redirect 抛出式 API;配合 useActionState 可回传校验错误。
如果项目流量涨了,你会怎么扩展这个 Next.js 服务?
先分层看:静态资产(SSG/ISR 页面)CDN 承接,扩容压力集中在动态路由;动态页可加缓存层(Data Cache + revalidate 策略、或前端加 Redis/Nginx 缓存);Node 实例水平扩容 + 负载均衡(注意无状态化,会话数据外置);后端 API 独立扩展;观测 TTFB 与错误率定位下一瓶颈。本站的 Docker + Nginx 架构就是这套思路的落地。
自测三问
Server Action 写数据比 Client fetch + 接口少了哪些代码?什么场景仍必须用 route.ts?
build 输出里出现意外的 ƒ 标记,你的排查步骤是什么?
列出上线检查清单中你认为最容易漏的三项,并说明漏掉的后果。
小结一句话 从 build 体检到环境变量注入,从 Server Action 写操作到 Vercel/服务器两种部署形态,走完这最后一公里,你就完成了从「会写 Next.js」到「能交付 Next.js 产品」的全部闭环。
延伸