现代 Next.js Mapped:各功能替代了什么以及何时使用它们
带你了解 Next.js 的八项核心功能,从服务器组件到元数据 API,逐一说明它们取代了哪些旧有模式,以及可能带来的隐患。
Next.js 现已涵盖了路由、数据获取、缓存、渲染策略以及大部分 API 功能。然而许多团队在采用它后仍保留旧习惯:在 useEffect 中进行数据获取,为每个表单手动编写 API 路由,以及使用没人能解释清楚的缓存规则。本指南介绍了这八项功能、它们所替代的旧有模式,以及在实际项目中会出问题的细节,帮助你逐项判断哪些功能应该纳入代码库。
为何该框架能覆盖整个技术栈
沃尔玛、耐克、TikTok、OpenAI 和 Airbnb 都使用 Next.js 运行 Web 应用,其优势在于功能整合:路由、打包、渲染模式、数据访问和服务器端点都共享同一个代码库和一套规范。选择路由器和配置打包工具已不再是项目启动的必要步骤,因此更多时间可以投入到产品开发中。
1. 服务器组件使服务器成为默认的渲染场所
React服务器组件(RSC)是此列表中最大的架构变革。服务器组件仅在服务器端执行,然后将渲染后的输出发送到浏览器,因此其代码永远不会成为客户端JavaScript包的一部分。
数据驱动页面中会消失什么
在传统的客户端渲染式React中,即使是一个仅用于读取数据并显示列表的组件,也会将其代码、数据获取逻辑以及依赖项添加到包中,再在浏览器中进行初始化。而使用RSC时,组件会在服务器端读取数据,仅传输结果。在App Router中,除非另有说明,否则每个组件都是服务器组件,这就是为什么下面的文件完全不需要任何指令的原因:
// app/products/page.tsx
// This component runs ONLY on the server. No "use client" directive needed.
该页面是一个async函数,它直接查询数据库并返回标记代码。中间没有API路由,也没有客户端请求:
import { db } from "@/lib/db";export default async function ProductsPage() {
// Direct database access. No API route. No fetch boilerplate.
const products = await db.product.findMany({ take: 20 }); return (
<main>
<h1>Our Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
<h2>{product.name}</h2>
<p>${product.price}</p>
</li>
))}
</ul>
</main>
);
}
没有useState,没有useEffect,没有加载状态标志,也没有向自身后端发起的fetch请求。浏览器中运行的代码更少,HTML内容会完整送达(有利于搜索引擎),且维护成本更低。由于这段代码直接操作数据库,切勿将其导入到客户端文件中;仅服务器端可用的数据模块能明确界定这一边界。
客户端组件仍适用的场景
对于任何需要交互的功能,仍然需要使用客户端组件:事件处理程序、状态管理、效果处理以及localStorage等浏览器API。只需在文件顶部添加"use client"指令即可标识此类文件:
// components/AddToCartButton.tsx
"use client";
下面的按钮会保存一段本地状态并响应点击操作,这正是需要在浏览器中完成的类型的工作:
import { useState } from "react";export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false); return (
<button onClick={() => setAdded(true)}>
{added ? "Added!" : "Add to Cart"}
</button>
);
}
从服务器组件开始,仅在需要交互性时再做调整,并尽可能将"use client"放在层级较深的位置:一个嵌入了小型客户端按钮的服务器页面所加载的JavaScript量,远少于从顶层就是客户端组件的页面。如需更深入地了解渲染模型背后的工作原理,请参阅零包渲染背后的架构。
2. 服务器动作替代API路由用于数据变更
Server Actions允许你编写在服务器上运行的函数,并直接从组件中调用它。Next.js会生成HTTP端点,序列化参数并返回结果,因此你无需再为处理表单提交而维护单独的路由。
它所取代的两文件模式
以前,任何数据变更操作都需要在Pages Router的api文件夹中编写处理程序,该程序负责读取请求体、向数据库写入数据并以JSON格式响应:
// You needed an API route
// pages/api/create-post.ts
export default async function handler(req, res) {
const { title, content } = req.body;
await db.post.create({ data: { title, content } });
res.status(200).json({ success: true });
}
随后组件必须手动调用该端点,并自行序列化请求数据:
// Then in your component:
const response = await fetch("/api/create-post", {
method: "POST",
body: JSON.stringify({ title, content }),
});
与表单一同存在的已验证操作
借助Server Actions,页面可以导入所需的一切资源,包括用于输入验证的Zod库:
// app/posts/new/page.tsx
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";
操作地址在表单旁边声明。函数体中的"use server"指令将其标记为仅服务器可执行的代码;表单会将该地址传递给其action属性。该操作会使用safeParse对FormData进行验证,若验证失败则返回字段错误信息,否则会处理提交数据并重定向:
const schema = z.object({
title: z.string().min(3, "Title must be at least 3 characters"),
content: z.string().min(10, "Content is too short"),
});async function createPost(formData: FormData) {
"use server"; const parsed = schema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
}); if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
} await db.post.create({ data: parsed.data });
redirect("/posts");
}export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write something..." required />
<button type="submit">Publish</button>
</form>
);
}
验证非常重要,因为服务器操作是一个公开端点,任何人都可以携带任意数据调用它。出于同样的原因,授权检查必须放在操作本身内部;为何服务器操作需要在每个函数体中加入授权检查对此有详细说明。另外请注意,此处并未读取返回的错误对象,因此验证失败时不会显示任何提示。接下来的模式可以解决这个问题。
使用 useActionState 显示错误与待处理状态
当表单需要显示验证信息,或在请求处理期间禁用按钮时,应将表单置于客户端组件中,并使用 React 的 useActionState 钩子来封装相关操作:
"use client";
该钩子会返回动作产生的最新状态、一个用于传递给表单的封装后的 formAction,以及一个 isPending 标志。组件会渲染每个字段的第一个错误,并在提交过程中切换按钮文本:
import { useActionState } from "react";
import { createPost } from "./actions";export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null); return (
<form action={formAction}>
<input name="title" placeholder="Post title" />
{state?.error?.title && (
<p className="text-red-500">{state.error.title[0]}</p>
)}
<textarea name="content" placeholder="Write something..." />
{state?.error?.content && (
<p className="text-red-500">{state.error.content[0]}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Publishing..." : "Publish"}
</button>
</form>
);
}
一个常令人困惑的细节是:当通过 useActionState 调用某个动作时,React 会以先前的状态作为第一个参数,再将 FormData 作为第二个参数传入。之前展示的 createPost 函数仅接受 formData,因此从 ./actions 导出的该钩子版本需要采用 (prevState, formData) 的参数签名。此外,该动作还必须保存在单独的文件中,并且在文件顶部标注 "use server",因为客户端组件无法直接内联定义服务器端函数。
3. Turbopack 缩短了开发反馈循环
长期以来,webpack一直主导着本地开发的节奏。一旦服务器启动,快速刷新功能使用起来相当便捷,但对于大型应用而言,冷启动却可能需要30到60秒的时间。Turbopack是Vercel推出的一款基于Rust的打包工具,旨在消除这一瓶颈。
Turbopack在全部8,298项Next.js集成测试中均实现了100%的通过率,那些原本需要超过一分钟才能完成冷启动的项目,使用该工具后开发环境的冷启动时间已降至3秒以内。其成熟度等级在各个版本间变化较快,因此请查阅最新文档以了解您所使用版本的现状,尤其是针对生产环境构建的情况。
启用该功能
在开发环境中启用此功能只需在dev脚本中设置一个标志即可:
// package.json
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build"
}
}
无需额外配置。Turbopack采用增量计算方式,仅重新生成发生变化的部分,因此对大型项目尤为有益。如果您依赖自定义的webpack加载器或插件,请先确认Turbopack中的对应组件。我们的打包工具对比文章详细分析了这些选择带来的权衡。
4. 部分预渲染:将静态外壳与流式数据结合
部分预渲染(PPR)会立即提供预先构建的静态HTML外壳,同时将同一页面的动态内容以流式方式注入其中,所有内容都在一次响应中完成。
在产品页面上,布局、导航和描述对所有人都是相同的;而个性化价格、购物车内容和库存数量则各不相同。PPR会立即发送静态外壳,然后在每次请求时根据实时数据动态填充相应内容。
该页面导入了一个静态组件和两个动态组件:
// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails"; // static
import { PersonalizedPrice } from "./PersonalizedPrice"; // dynamic
import { StockStatus } from "./StockStatus"; // dynamic
静态与动态组件的边界由 Suspense 标记。边界之外的内容都可以预先渲染;每个 Suspense 降级处理都会在壳层中生成一个占位符,直到其子组件在服务器端完成渲染后才会被替换:
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* This renders statically - instant */}
<ProductDetails id={params.id} /> {/* These stream in dynamically */}
<Suspense fallback={<div>Loading price...</div>}>
<PersonalizedPrice productId={params.id} />
</Suspense> <Suspense fallback={<div>Checking stock...</div>}>
<StockStatus productId={params.id} />
</Suspense>
</div>
);
}
请注意,此处 params 被定义为普通对象类型。在较新的 Next.js 版本中,params 会以 Promise 的形式传递并需要等待其解析,因此需根据目标版本调整该参数的类型定义。
PPR 是通过 Next.js 配置中的实验性标志引入的,首先是从类型导入开始的:
// next.config.ts
import type { NextConfig } from "next";
随后才是该标志本身:
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};export default nextConfig;
在撰写本文时,此标志已在 newer 版本中进行了重新设计(Next.js 16 中的 PPR 行为与 Cache Components 设置相关),因此请将此代码片段视为示例,并确认当前的选项名称。无论如何,由于页面外壳被缓存而其数据保持实时更新,所以该页面看起来是静态的。我们将在partial pre-rendering and concurrent rendering explained一文中更深入地探讨其原理。
5. use cache 指令使缓存机制更加明确
Next.js 13 和 14 默认会进行大量缓存,许多团队因此在生产环境中发现了无明显原因的过时页面。Next.js 16 则通过 Cache Components 和 use cache 指令引入了按需缓存机制:你需要明确指定哪些内容需要被缓存,而非猜测哪些内容已经被缓存。
将该指令放在异步组件的顶部可以缓存其渲染结果:
// A component that caches its output for 1 hour
async function PopularArticles() {
"use cache";
该组件的其余部分则按常规方式获取数据并进行渲染:
const articles = await fetch("https://api.example.com/popular-articles").then(
(r) => r.json()
); return (
<ul>
{articles.map((article: { id: string; title: string }) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
);
}
注释中提到一小时,但该指令本身并未设定持续时间;其有效期来自通过cacheLife应用的缓存配置文件,否则将使用默认配置文件。自定义配置文件可在配置中声明:
// next.config.ts
const nextConfig = {
experimental: {
cacheLife: {
"stale-for-a-day": {
stale: 60 * 60, // 1 hour
revalidate: 60 * 60 * 24, // 1 day
expire: 60 * 60 * 24 * 7, // 1 week
},
},
},
};
这三个值对应不同的问题。stale表示客户端在未检查服务器的情况下可以使用其副本的时间长度,revalidate表示服务器在后台刷新该条目的频率,而expire则是该条目被丢弃、后续请求必须等待新数据的时刻。cacheLife是否属于experimental功能取决于您的版本。关于在数据写入后需要使用的基于标签的失效机制,请参阅我们关于使用缓存及基于标签的重新验证的指南。
6. 使用 AI SDK 实现流式 AI 功能
Vercel AI SDK 能与路由处理程序及 React 钩子功能集成,因此流式聊天、AI 辅助搜索以及自动生成的 UI 都能通过常规应用程序代码实现。
在服务器端,路由处理程序会导入 streamText 以及模型提供器:
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
POST 处理程序从请求体中读取对话内容,启动流式处理并以流式响应的形式返回结果(代码片段中函数的闭合大括号被截断了):
export async function POST(req: Request) {
const { messages } = await req.json(); const result = streamText({
model: openai("gpt-4o"),
messages,
}); return result.toDataStreamResponse();
在客户端,聊天页面属于客户端组件,因为它负责管理输入状态:
// app/chat/page.tsx
"use client";
useChat 钩子负责管理消息列表、输入值及提交操作,并在有新消息到达时重新渲染页面:
import { useChat } from "ai/react";export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit } = useChat(); return (
<div>
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
</div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
<button type="submit">Send</button>
</form>
</div>
);
}
一个流式聊天应用大约需要30行代码,后端包含路由处理程序,前端则使用useChat函数。需要注意的是,AI SDK的API更新很快:在较新的主要版本中,该钩子函数是从@ai-sdk/react导入的,输入状态需要由开发者自行管理,且响应处理函数的名称也有所变化。在复制此代码之前,请锁定所需的版本并查阅SDK文档。对于需要使用工具和多步骤的智能体界面,可参考使用Next.js和AI SDK构建多步骤AI智能体界面的相关内容。
7. 用于复杂布局的App Router模式
Next.js 13引入的App Router现已成为构建Next.js应用的标准方式。它的两项功能取代了以往需要自定义状态管理的做法。
独立仪表板面板的并行路由
并行路由允许在同一个布局中同时渲染多个页面。每个以@为前缀的文件夹都定义了一个命名槽位:
app/
dashboard/
@analytics/
page.tsx
@recent/
page.tsx
layout.tsx
page.tsx
布局会将每个槽位作为属性与children一同接收,然后将其放入网格中:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
recent,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
recent: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<aside>
{analytics}
{recent}
</aside>
</div>
);
}
由于每个槽位都是独立的路由段,它能够加载自己的数据,并拥有独立的加载状态和错误状态。缓慢的分析查询不会影响近期活动面板的功能。需要注意的一点是:当导航到某个槽位未定义的子路径时,Next.js需要该槽位中存在default.tsx文件,以便在强制重新加载时知道应渲染什么内容。
使用真实URL的模态框路由拦截
一种常见的界面模式是在模态窗口中打开某个项目,同时将URL更改为该项目的地址,以便实现内容共享。路由拦截器通过文件夹约定来实现这一功能:
app/
photos/
[id]/
page.tsx // Full page view at /photos/123
(..)[id]/
page.tsx // Intercepted modal view
page.tsx
当用户从网格中点击时,拦截文件夹会捕获客户端发起的导航请求并渲染模态版本;而如果有人直接访问该URL或刷新页面,则会正常渲染完整页面,无需任何手动处理历史记录的技巧。(.)、(..)和(...)这些标记是相对于路由路径段而言的,而非文件系统中的文件夹,而且模态窗口通常是通过独立的@modal插槽来渲染的,因此请查阅路由文档以了解您的结构所需的具体布局。
8. SEO基础:元数据与图片
Metadata API 将 SEO 转换为普通代码,这些代码会与它所描述的页面一同存在。在导入 Metadata 类型之后:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
页面会输出 generateMetadata 方法,该方法加载文章内容并返回标题、描述、Open Graph 数据以及 Twitter 卡片信息:
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug); return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage }],
type: "article",
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
社交预览、规范 URL 以及结构化数据都来源于页面渲染的相同数据。如果页面组件也调用了 getPost 方法,应通过某种方式(例如 React 的 cache)避免重复请求,从而防止数据库被多次查询。
对于图片处理,建议默认使用 next/image:
import Image from "next/image";
它默认采用懒加载方式,提供合适尺寸的图像版本,并在浏览器支持时将其转换为WebP或AVIF格式。明确指定width和height还能预留空间,避免布局偏移:
export function ProductCard({ product }: { product: Product }) {
return (
<div>
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
priority={false} // set true for above-the-fold images
/>
<h2>{product.name}</h2>
</div>
);
}
仅将priority设置为true用于那些位于可视区域顶部的图像,通常是主图或核心产品图片,这样浏览器就能提前获取这些图像。
合理的学习顺序
这里的每一步都是在前一步的基础上进行的:
- App Router的文件规范:
layout.tsx、page.tsx、loading.tsx和error.tsx。 - 服务器组件以及客户端与服务器的边界位置。
- 针对每次数据变更使用Zod进行验证的服务器动作。
- 用于声明式加载状态的Suspense与流式处理技术。
核心要点
- 将服务器视为默认运行环境,并将
"use client"设置应用到最底层的交互组件。 - 服务器动作即端点:需在每个动作中验证输入并检查权限。
- 使用
Suspense来划分静态与动态内容边界,PPR 和缓存功能均基于此构建。 - 建议使用
use cache及带名称的cacheLife配置进行显式缓存,而非依赖默认设置。 - 近期版本中许多 API 都发生了变化,因此请根据实际运行的版本确认相关标志和签名。
无论你是从零开始还是将大型应用迁移到 App Router,逐步采用这些功能都是风险较低的方式。
相关阅读
- 面向高级应用架构的20种Next.js 16进阶模式 — 梳理了服务器优先设计、缓存、流式传输、PPR、并行路由与拦截路由等用于构建可扩展Next.js 16应用程序的模式。
- 了解Next.js 16.3中的缓存组件与部分预加载功能 — 阐述了Next.js 16.3的即时导航功能如何通过共享路由框架及明确的流式传输策略,让服务器渲染的应用具备即时响应的效果。