本文以英文发布。
将 Express API 迁移至 Next.js App Router 路由处理程序
了解如何将 Express 路由、中间件及数据模式转换为使用服务器组件的 Next.js App Router,以及相关的部署注意事项。
何时迁移至 Next.js 才有意义(以及何时没有意义)
当你的项目满足以下至少两个条件时,Next.js App Router 就会成为合理的选择:
- 初始加载速度或搜索引擎可见性至关重要,且你目前使用的是在页面加载后从 Express 后端获取数据的单页应用。
- 你的部署流程已经针对 Vercel 或类似平台,或者你准备采用这类基础设施——App Router 的边缘计算和无服务器功能依赖于这样的托管环境。
- 你的 Express 应用主要负责页面渲染和处理常规的 CRUD 操作,而非管理持久连接、后台任务或计算密集型进程。
以下情况不建议进行迁移:
- 您的后端需要处理除页面渲染之外的大量工作——如消息队列、定时任务、gRPC 接口或持久化套接字连接。Next.js 的路由处理函数无法替代专用的后端服务;您很可能会将 Next.js 作为前端层部署,同时在背后运行独立的 Express 或 Node 服务。
- 您严重依赖成熟的 Express 中间件生态系统——诸如专业的身份验证服务、速率限制库或可观测性集成工具——而为了微小的收益却需要对这些组件进行彻底的重构。
- 您的应用主要由需要身份验证的交互式控制台界面构成,对搜索引擎的功能需求有限——在这种情况下,App Router 的主要优势(流式服务器渲染、搜索优化、静态生成)几乎无法发挥作用,同时还需要投入大量精力来学习相关技术。
一种推荐的做法是:将 Express API 保留为数据和业务规则的权威来源,再将 Next.js 作为渲染层及前端后端服务层。首先迁移用户界面和以读取为主的路由,而那些需要大量写操作的内部服务则保持不变。本指南遵循这一迁移策略。
将 Express 路由映射到 Next.js 路由处理函数
位于 app/api/**/route.ts 中的路由处理函数可以最直接地替代 Express 的路由定义。概念上的变化在于:不再使用 req 和 res 对象,而是接收一个 Request 对象并返回一个 Response 对象(或为方便使用 NextResponse),而且每个 HTTP 方法都变成了一个独立的导出函数,而非 router.get() 方法调用。
考虑一个用于获取和创建订单的标准 Express 路由:
// express: routes/orders.ts
import { Router } from "express";
import { db } from "../db";
import { requireAuth } from "../middleware/auth";
const router = Router();router.get("/api/orders", requireAuth, async (req, res) => {
const userId = req.user.id;
const orders = await db.order.findMany({ where: { userId } });
res.json({ orders });
});router.post("/api/orders", requireAuth, async (req, res) => {
const { items } = req.body;
if (!items?.length) {
return res.status(400).json({ error: "items required" });
}
const order = await db.order.create({
data: { userId: req.user.id, items },
});
res.status(201).json({ order });
});export default router;
对应的路由处理程序实现:
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} const orders = await db.order.findMany({ where: { userId: user.id } });
return NextResponse.json({ orders });
}export async function POST(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} const body = await req.json();
if (!body.items?.length) {
return NextResponse.json({ error: "items required" }, { status: 400}); } const order = await db.order.create({ data: [0], }); return NextResponse.json([1], [2]); }
将 Express 模式转换为路由处理函数时,有几点细节需要注意:
动态路径段使用基于文件夹的语法,而非冒号前缀。Express 中的路由 /api/orders/:id 会对应为 app/api/orders/[id]/route.ts。参数作为处理函数的第二个参数传入:GET(req, [3]: { params: Promise })。当前版本的 Next.js 会将参数以承诺的形式返回,因此必须在读取值之前先使用 await 响应它。
不存在按路由划分的中间件链。 Express中的requireAuth中间件要么会转换为在每个处理函数开始时调用的共享工具函数(如上例所示),要么更理想的是将其逻辑放入middleware.ts中(将在后续章节讨论),这样各个路由处理函数就不必关心认证相关问题。
请求体解析需要显式调用——你需要编写await req.json(),而不能依赖express.json()。不会自动进行解析,这实际上提升了代码的清晰度:你可以避免因数月前配置却遗忘的全局中间件所导致的意外请求体大小限制。
路由处理函数依然是普通的 Node 或 Edge 函数。如果您在 Express 路由中使用了 zod 进行输入验证,那么这些验证代码无需修改即可直接使用。
服务器组件与现有的客户端渲染 React 模式
相比其他方面,这一点最容易让团队措手不及。在 Express 加 React 的架构中,所有组件默认都是客户端组件:它们在浏览器中渲染,当需要数据时,会通过 useEffect 或 React Query 等数据获取库来调用您的 API。
// 旧模式:由客户端渲染的 React 与 Express 进行通信
function OrderList() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders", { credentials: "include" })
.then((r) => r.json())
.then((data) => setOrders(data.orders));
}, []); if (!orders) return <Spinner />;
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
App Router 反转了这一默认设置。除非另有指定,否则每个组件都是服务器端组件,这意味着它们在服务器上执行,可直接访问数据库或服务,且永远不会将 JavaScript 发送到客户端。对于属于当前页面的数据,完全可以跳过 API 路由。
直接查询它即可:// app/orders/page.tsx — 服务器端组件,无需使用“use client”
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login"); // 直接访问数据库,无需fetch操作,无加载状态,也不产生客户端代码包的开销
const orders = await db.order.findMany({
where: { userId: user.id }rver 提供的 HTML 已经包含数据。只有当某个组件需要交互功能时,比如状态管理、效果处理、事件处理器或仅浏览器支持的 API,才需要添加 "use client":// app/orders/OrderFilters.tsx
"use client";
import > from "react";
import > from "next/navigation"; export function OrderFilters() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState(params.get("status") ?? "all"); function apply(next: string) {
setStatus(next);
const url = new URLSearchParams(params);
url.set("status", next);
router.push(`/orders?$>`);
} return (
<select value=> onChange=>>
<option value="all">全部</option>
<option value="pending">待处理</option>
<option value="shipped">已发货</option>
</select>
);
}
/code> 给团队的实用建议:尽可能将“use client”指令推送到组件树的深层位置。保持页面和布局为服务器端组件;仅将此指令应用于确实需要交互性的叶子组件。如果出于习惯为每个组件都添加“use client”(在移植单页应用时未重新考虑架构就会出现这种情况),那么你就无法从App Router中获得任何优势,甚至会比原来的架构更难以理解。
用Next.js中间件替换Express认证中间件
在Express中,认证中间件会在Node.js进程中的每个路由上运行。Next.js提供了middleware.ts,它可以在请求到达任何页面或处理程序之前在边缘层拦截请求,从而起到相应的架构作用:
// 旧版本:middleware/auth.ts(Express)
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (!token) return res.status(401).json([0]);
try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
next();
} catch {
res.status(401).json([1]);
}
}
// middleware.ts — 位于项目根目录
import { ServerRequest, ServerResponse } from "next/server";
import { JWTVerifier } from "jose"; // 与jsonwebtoken不同,更兼容Edge环境
const PROTECTED_PREFIXES = [/"/dashboard"/, "/"/orders"/, "/"/api/orders"/];
export async functionn middleware(req: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((p) =>
req.nextUrl.pathname.startsWith(p)
);
if (!isProtected) return NextResponse.next(); const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
} try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret); // forward the verified user id downstream via a request header
const headers = new Headers(req.headers);
headers.set("x-user-id", String(payload.sub));
return NextResponse.next({ request: { headers } });
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}export const config = {
matcher: ["/dashboard/:path*", "/orders/:path*", "/api/orders/:path*"],
};
两个
迁移团队总会遇到以下问题:- 默认情况下,中间件是在 Edge 运行时上运行的,而非 Node.js。任何依赖 Node.js 核心模块的库——如
jsonwebtoken、常见的数据库客户端——都会出现故障或隐性错误。应选用适用于 Edge 的替代方案:jose是处理 JWT 操作的常规选择。所有数据库查询都应放在具备完整 Node.js 环境的服务器组件或路由处理程序中执行,绝不要在中间件中进行此类操作。
SELECT * FROM sessions WHERE id = ? 这样的查询。应将中间件逻辑限制在快速、无状态的操作上,比如令牌签名验证。而“该用户能否查看此订单?”这类授权问题则应交由页面组件或处理程序本身来处理,因为在这些地方可以完全使用数据库访问功能和 Node.js API。数据获取模式:服务器组件与现有的 API 调用方式
大多数基于 Express 和 React 的架构都遵循相同的流程:组件加载后从 API 获取数据,API 再查询数据库,JSON 数据返回到浏览器,最后组件更新界面。这种通过两次网络传输——从客户端到服务器、再从服务器到数据库——来获取本就存在于服务器上的信息的方式效率并不高。
借助 App Router,服务器组件中的读取操作可以简化为单次请求:服务器查询数据库后,直接将包含结果的 HTML 流式传输给客户端,正如之前的 OrdersPage 代码所示。至于写入操作,有两种常见的实现方式:如果需要传统的 API(例如公共的 REST 接口),则使用路由处理程序;而如果是通过自定义表单和用户界面触发的数据变更,则使用服务器动作。
服务器动作与 Express 的常规做法差异最大。你需要定义一个在服务器上运行的函数,然后直接从表单中调用它,而无需创建任何显式的 API 路由:
// app/orders/actions.ts
"use server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";export async function createOrder(formData: FormData) {
const user = await getSessionUser();
if (!user) redirect("/login"); const itemId = formData.get("itemId");
if (typeof itemId !== "string" || !itemId) {
throw new Error("需要提供 itemId");
} await db.order.create({
data: { userId: user.id, items: [{ itemId, qty: 1 }] },
}); // 使用最新的服务器数据重新渲染订单页面——无需客户端重新获取数据
revalidatePath("/orders");
}
// app/orders/NewOrderForm.tsx
import { createOrder } from "./actions";
export function NewOrderForm() {
return (
<form action={createOrder}>
<input type="text" name="itemId" placeholder="项目编号" required />
<button type="submit">创建订单</button>
</form>
);
}
服务器动作与路由处理程序的选择:
服务器动作是指用户界面向服务器发起的内部函数调用;而路由处理程序则是正式的HTTP端点。在确定哪种模式适合特定的操作时,这种区别非常重要。
当端点需要保持稳定,并且可以从 Next.js 应用程序外部调用——比如移动客户端、第三方集成或公共 API 时,应使用路由处理程序。路由处理程序能让你明确指定 URL 路径、HTTP 动词,同时由你掌控并管理其版本。
当数据变更源自你自己的表单和交互组件时,应使用服务器动作。服务器动作无需大量冗余代码,还能通过 revalidatePath 或 revalidateTag 自动重新验证缓存数据,从而省去在 fetch 调用后手动打破缓存的操作。它们并非为外部使用者或向后兼容性保障而设计,应将其视为内部远程过程调用。
实际测试:如果您需要为团队外的其他人提供接口文档,应将其设为路由处理程序。如果该接口仅用于支持界面中的按钮或表单,那么使用服务器动作会更简单。
部署差异(Vercel vs ECS/EB)
那些习惯在ECS或Elastic Beanstalk上部署Express应用的团队,在使用Vercel上的Next.js时会面临根本性的架构变化。Express应用以长期运行的Node进程形式存在:一个进程可处理大量请求,保持数据库连接处于活跃状态,并具有可预测的内存占用和启动特性。
在 Vercel 上使用 Next.js 时,页面和路由处理程序会作为独立的无服务器函数或边缘函数进行部署。每个函数都会独立启动,受自身执行限制的约束,并且不会像单个 Express 进程那样共享持久的数据库连接池。如果你以在 Express 中相同的方式创建 Prisma 客户端,那么在高负载情况下就会耗尽数据库连接限制,因为每次函数调用都会创建自己的连接。
// lib/db.ts — 无服务器 Prisma 所需的架构
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };// 在多次调用时重用客户端,而非每次都创建新实例
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// 使用连接池化的连接字符串(例如 PgBouncer / Prisma Accelerate / RDS Proxy)
datasources: { db: { url: process.env.DATABASE_URL_POOLED } },
});if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
该架构可在多次调用时重用客户端,而无需在每次请求时都创建新实例。
如果将 Next.js 部署在 ECS 或 Elastic Beanstalk 而非 Vercel 上,该框架将以传统 Node 服务器的模式运行。可以依次执行 next build 和 next start,或者配置独立输出模式以生成更精简的 Docker 镜像。虽然仍能享受长进程运行的优势,但却无法使用 Vercel 的自动边缘网络、CDN 上的增量静态资源重新生成功能以及无需配置即可进行的预览部署。这些功能必须自行配置,否则就得接受其缺失的状态。这属于合理的工程选择,并非妥协;许多团队之所以在 ECS 上运行 Next.js,正是因为他们已拥有相关基础设施,且不愿将托管服务分散到两个提供商处。
需为另外两项迁移任务分配时间:环境变量(Next.js要求任何暴露给浏览器的变量都必须使用NEXT_PUBLIC_前缀——请仔细检查传递给React构建过程的每一个变量),以及编译时配置与运行时配置(嵌入到静态导出中的值与在请求时读取的值行为不同,而单个Express进程则所有内容均为运行时处理)。
迁移检查清单与常见陷阱
请按顺序执行以下步骤:
- 在初始设置阶段,同时部署Next.js应用和Express服务器——不要改动现有应用。
- 首先转换那些需要大量读取数据且经过搜索引擎优化的页面——这类页面最能从服务器组件中受益,迁移风险也最低。
middleware.ts 中使用与边缘运行时兼容的令牌验证库来实现身份认证。next build && next start)之后,最后再调整部署和持续集成流程——next dev会掩盖仅在生产模式下出现的某些错误,比如违反服务器/客户端边界规则的情况。迁移过程中常见的错误:
- 将仅适用于服务器的代码导入到客户端组件中。当带有
"use client"属性的组件导入了任何可以访问fs、数据库客户端或机密信息的代码时,构建过程要么会失败,更危险的是——这些机密信息会被打包进客户端JavaScript文件中。应使用server-only包,这样一旦出现问题就会触发构建错误,而不会导致凭证被悄悄泄露。
revalidatePath 或 revalidateTag。由于服务器组件可能会被缓存,若不进行显式重新验证,界面在数据发生变化后仍会显示过时信息。"use client" 标记。从单页应用沿袭下来的习惯是导致 Next.js 应用无法超越原有应用性能的主要原因。总结
如果仍不确定,可从小型实验开始:从当前应用中挑选一个对读取性能要求高且对SEO至关重要的页面,将其重构为能够直接查询数据库或调用现有Express API的服务器组件,并部署在Express服务器未处理的路由上。在迁移更多页面之前,先比较首次数据传输时间与代码包大小。确认该方法有效后,再将认证逻辑移至middleware.ts中,随后逐个将流量最大的业务流程转换为服务器动作——逐步迁移比全面重写更为安全且实用。那些遇到问题的团队,往往是未先弄清服务器组件究竟在哪些地方真正能减轻工作量、又在哪些地方只是为现有系统增添了新的概念层,就直接对整个代码库进行迁移的团队。
相关阅读
- 构建具有清晰状态所有权的 React 搜索界面 —— 将事件、派生值、请求生命周期以及结果展示分开处理,从而确保正确的答案对应到合适的查询。
- 通过定位首个失败阶段来调试 RAG —— 区分数据源、检索、生成以及运行层面的故障,让每次实验都能解决明确识别的问题。
- TypeScript 6 在通往原生 TS 7 编译器的道路上的桥梁作用 —— 了解 TypeScript 6 如何更新默认配置、模块解析机制以及导入语法,为更快、基于 Go 语言的 TypeScript 7 编译器做好代码库准备。