假设我们正在做一张商品页。商品名称、价格和库存来自数据库;搜索框要随着输入立即过滤;收藏按钮要记住当前状态;支付密钥绝不能出现在浏览器里。
如果把这些需求全塞进一个带有 'use client' 的页面,当然也能写,但浏览器会拿到更多 JavaScript,首屏取数还可能多绕一趟。如果为了减少 JavaScript 又把所有东西都留在服务器,搜索框和收藏按钮就无法响应用户操作。
真正要做的不是在“服务器组件”和“客户端组件”之间二选一,而是把一张页面切成合适的边界:服务器准备内容,客户端只接管需要持续交互的区域。后面所有规则,都是在帮我们把这条线画准。

本节讨论的是 App Router。当前项目使用 React 19 和 Next.js 16。React Server Components 的组件模型在 React 19 中已经稳定,不能再沿用“React 18 的实验性功能”这一旧口径。React 用来实现框架和打包器的底层接口仍可能随版本调整,这是框架作者需要处理的问题,不等于日常使用的 Server Component 仍处于实验状态。
第一次接触这两个名字时,我们很容易把它们理解成两套彼此排斥的组件。其实它们仍然都是 React 组件,区别在于组件代码在哪个环境中执行,以及相应代码是否要进入浏览器的 JavaScript 包。
在 App Router 中,page.tsx 和 layout.tsx 默认是 Server Components。它们可以直接读取服务器数据层、文件系统和私有环境变量,也可以写成异步组件,在渲染过程中等待数据。
下面的页面读取服务器本地文件。浏览器最终能看到公告内容,但不会下载 node:fs 和读取文件的组件代码。
// app/notice/page.tsx
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default async function NoticePage() {
const filePath = path.join(process.cwd(), 'data', 'notice.txt')
const notice = await readFile(filePath, 'utf8')
return (
<main>
<h1>站点公告</h1>
<p>{notice}</p>
</main>
)
}这里的“服务器”不一定等于“每次请求时的某台长驻服务器”。Server Component 可以在构建阶段预先运行,也可以在请求到来时运行。具体时机由数据、动态 API 和缓存策略共同决定,这些内容会在下一章展开。
只要组件需要在用户操作之后继续变化,就需要浏览器里的 React 参与。状态、事件处理器、Effect、浏览器 API 和依赖这些能力的自定义 Hook,都是常见信号。
// app/ui/like-button.tsx
'use client'
import { useState } from 'react'
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button type="button" onClick={() => setLikes((
useState 要在多次浏览器渲染之间保留值,onClick 也要在浏览器中接收事件,所以这个文件是一个客户端入口。
“Client Component”这个名字最容易造成一个误会:好像它的 HTML 必须等浏览器下载 JavaScript 后才出现。
Next.js 首次加载一条路由时,会在服务器上为 Server Components 和 Client Components 共同预渲染 HTML。区别在于,Client Component 的代码随后还要发给浏览器并完成水合;Server Component 本身的代码不会进入客户端包,也不会在浏览器中再次执行。
因此,客户端组件要按照浏览器安全模型编写。即使它在首次加载阶段参与了服务器预渲染,也不能读取私有密钥或 import 服务器专属模块,因为同一份客户端模块最终还会下载到浏览器。
Server Components 和服务端渲染(SSR)描述的是两件不同的事。
Server Component 说明组件代码在哪个模块环境中执行,以及它的渲染结果怎样进入 React Server Component Payload。SSR 说明服务器是否为首次访问生成 HTML。首次加载时,Client Component 也可以参与 SSR;反过来,Server Component 的价值也不只是生成 HTML,它还参与后续导航的 RSC 数据更新。
你可以先记住一句话:RSC 决定组件树如何跨越服务器与客户端,SSR 决定首次页面是否拿到预渲染 HTML。
只说“服务器把 HTML 发给浏览器”已经不足以解释 App Router。除了 HTML,React 还需要一份能够描述服务器组件树的特殊数据。
Next.js 会按路由段组织渲染工作,并把 Server Components 的结果编码进 React Server Component Payload,下面简称 RSC Payload。
这份载荷主要包含三类信息:
它不是把 Server Component 源码发送出去。数据库连接、文件读取逻辑和只在服务器使用的大型库不会因为出现在 Server Component 中就进入浏览器包。
首次打开页面时,可以把浏览器的工作理解成三个连续动作。
浏览器先使用服务器生成的 HTML 显示一个快速但尚未完全可交互的页面。标题、商品文字和按钮外观已经可以出现。
React 使用 RSC Payload 对齐服务器组件树与客户端组件占位,知道哪些内容已经由服务器完成,哪些位置需要客户端模块接手。
浏览器下载客户端 JavaScript,并为 Client Components 水合,也就是绑定事件处理器、恢复状态能力,让按钮和输入框真正开始工作。
Server Components 不需要水合,因为它们的组件代码根本不会在浏览器中运行。水合针对的是 Client Components。
用户通过 Next.js 的 Link 在应用内部导航时,通常不会重新下载一整份文档。Next.js 可以提前预取目标路由的 RSC Payload,并把它放进客户端路由缓存。点击发生后,React 使用新的载荷更新需要变化的路由区域,同时保留共享布局和仍然存在的客户端状态。
后续导航中不会再依赖目标路由的服务器预渲染 HTML 来完成 Client Components 的更新。客户端已有的组件代码负责渲染客户端部分,新的服务器结果则通过 RSC Payload 合并进现有树。

下面的模拟器把这两条路径放到同一张工作台里。先选择“首次加载”,观察 HTML、载荷和 JavaScript 的出场顺序;再切到“后续导航”,比较哪些材料不再重复传输。
RSC Payload 是面向 React 的传输格式,不是秘密保险箱。凡是从 Server Component 传给 Client Component 的 props,都应按“浏览器可以观察到”处理。格式不是普通 JSON,并不会让敏感数据自动变安全。
判断一个组件应该放在哪边,不要先问“它是不是页面”,而要问“它用到了哪一边独有的能力”。
需要 useState、useReducer,或者要处理 onClick、onChange、拖拽、键盘输入时,组件需要成为客户端模块图的一部分。
不过,交互不一定都要求 React JavaScript。一个只需要展开和收起的静态说明,有时原生 details 元素就够了;一个悬停样式可以交给 CSS。先看浏览器平台能否完成,再决定是否增加客户端状态。
useEffect、useLayoutEffect 依赖客户端组件的挂载和更新过程。usePathname、useSearchParams 等读取客户端导航状态的 Hook 也需要客户端入口。
不能把规则粗暴地写成“Server Component 禁止一切 Hook”。React 的服务器环境有自己的能力,例如异步组件、cache 和在支持场景中使用 use。我们真正要排除的是依赖浏览器生命周期、可变客户端状态或客户端 Context 的 Hook。
下面这些对象只存在于浏览器环境:
window、document 和 navigator。localStorage、sessionStorage。ResizeObserver、IntersectionObserver。使用它们的组件需要在客户端模块图中,而且通常要在事件处理器或 Effect 中访问,避免首次预渲染阶段直接求值。
下面这些工作更适合 Server Components 或服务器专属数据层:
留在服务器并不保证页面自动变快。慢查询仍然是慢查询,串行数据请求仍可能形成瀑布。Server Components 解决的是执行位置和客户端包边界,不会替我们消除所有性能问题。
一个只接收 props 并返回 JSX 的展示组件,不一定需要主动声明身份。
// app/ui/price.tsx
export default function Price({ cents }: { cents: number }) {
const formatted = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(cents / 100)
return <span>{formatted}</span>
}如果 Server Component import 它,这次用法会留在服务器图中。如果 Client Component import 它,它会成为客户端依赖。身份由模块图中的使用路径决定,不是每个纯展示文件都要写一条指令。
先问组件是否读取数据库、私有文件、请求凭据或服务器环境变量。如果是,读取逻辑必须留在服务器,并只返回允许公开的数据。
再问组件是否需要在用户操作后保留状态、响应事件或执行 Effect。如果是,把真正需要这些能力的区域设为客户端入口。
接着检查它是否依赖 window、localStorage 或客户端导航 Hook。只要存在这种依赖,相关模块就不能作为纯服务器模块执行。
最后向下移动边界。不要因为页面中有一个搜索框,就把页面、导航、文章正文和页脚全部纳入客户端包。
'use client' 看起来只是一行字符串,实际作用是给模块依赖图画出一个客户端入口。
指令必须放在文件顶部、所有 import 之前。Server Component 直接 import 这个文件时,打包器会在这里建立 Server/Client 边界。
不需要给客户端子树里的每一个文件重复写指令。已经被客户端入口 import 的模块,本来就会进入客户端图。重复标记不会让组件“更客户端”,反而会制造更多可被服务器直接引用的边界入口,并让 props 约束更难理解。
假设 Search 是客户端入口,它 import 了 suggestions.ts 和 highlight.tsx。这两个文件即使没有指令,也会跟着进入客户端包。
app/layout.tsx 服务器
├── import Logo 服务器
└── import Search 客户端入口
├── import suggestions 客户端依赖
└── import Highlight 客户端依赖所以边界位置会直接影响客户端 JavaScript 的范围。把指令放在 layout.tsx,可能把导航、Logo、格式化工具和其他静态组件一起带进浏览器;放在 Search,客户端只承担搜索所需的代码。
模块树回答“谁 import 了谁”,渲染树回答“最终 JSX 看起来是谁包着谁”。两棵树经常相似,但不是一回事。
一个 Client Component 在视觉上可以包住 Server Component,只要服务器父组件先创建这段 Server Component JSX,再通过 props 或 children 交给客户端外壳。客户端外壳没有 import 那个服务器模块,因此它不会进入客户端模块图。

下面的布局没有状态,因此继续留在服务器。只有搜索框需要客户端能力。
// app/layout.tsx
import Logo from './ui/logo'
import Search from './ui/search'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<nav>
// app/ui/search.tsx
'use client'
import { useState } from 'react'
export default function Search() {
const [query, setQuery] = useState('')
return (
<label>
搜索
<input
value={query}
onChange={(event) =>

下面的边界实验室允许你把指令放在不同节点。重点观察两件事:import 边会把依赖拉进客户端包,而 children 插槽边不会。
现在把前面的判断落到一个完整例子里。需求是:首屏显示商品数据,用户输入时在本地立即过滤。
// app/products/page.tsx
'use client'
import { useEffect, useState } from 'react'
export default function ProductsPage() {
const [products, setProducts] = useState([])
const [query, setQuery] = useState('')
useEffect(() => {
fetch('/api/products')
.
这个写法不是永远错误。需要轮询、离线缓存、浏览器事件驱动刷新,或者已有成熟客户端数据层时,客户端取数有合理用途。但对“打开页面就要显示”的首屏商品来说,它让浏览器先下载和运行 JavaScript,再发起第二次请求,同时还需要自己维护加载状态。
// app/products/product-types.ts
export type ProductSummary = Readonly<{
id: string
name: string
priceInCents: number
}>这个文件没有数据库代码,也没有浏览器 API,服务器数据层和客户端搜索组件都可以安全地使用它。
下面假设项目已经配置了 db 数据库适配器。重点不是具体 ORM,而是 server-only、字段白名单和明确的返回类型。
// app/products/data.ts
import 'server-only'
import { db } from '@/lib/db'
import type { ProductSummary } from './product-types'
export async function getProductSummaries(): Promise<ProductSummary[]> {
return db.product.findMany({
select: {
id: true,
name: true,
priceInCents: true,
},
orderBy: {
这里没有 SELECT *,也没有把成本价、供应商密钥或内部备注交给页面。越靠近数据源完成最小化,后面误传整条记录的机会越小。
// app/products/page.tsx
import { getProductSummaries } from './data'
import ProductSearch from './product-search'
export default async function ProductsPage() {
const products = await getProductSummaries()
return (
<main>
<h1>商品目录</h1>
<p>共 {products.length} 件商品</p>
<
页面本身没有客户端状态。它在服务器取得公开 DTO,再把 DTO 传给真正需要交互的搜索组件。
// app/products/product-search.tsx
'use client'
import { useState } from 'react'
import type { ProductSummary } from './product-types'
const currency = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
})
export default function ProductSearch({
products,
}: {
products
这次拆分后,数据库访问和首屏数据留在服务器;浏览器只下载过滤交互所需的代码。服务器与客户端不是按文件数量平均分工,而是按能力边界分工。
服务器组件可以 import 客户端入口并渲染它。反过来,客户端模块不能直接 import 一个依赖数据库、文件系统或私有环境变量的 Server Component。
// app/ui/modal.tsx
'use client'
import Cart from './cart' // Cart 读取数据库,这个方向不成立
export default function Modal() {
return <Cart />
}因为 modal.tsx 已经建立客户端入口,它的 import 依赖会进入客户端模块图。一个导入 server-only 数据层的 Cart 无法跨过这条边。
客户端弹窗只负责开关状态,并预留一个 children 插槽。
// app/ui/modal.tsx
'use client'
import { useState, type ReactNode } from 'react'
export default function Modal({
triggerLabel,
children,
}: {
triggerLabel: string
children: ReactNode
}) {
const [open, setOpen] = useState(false)
return
购物车继续在服务器读取数据。
// app/ui/cart.tsx
import { getCartSummary } from '@/data/cart'
export default async function Cart() {
const cart = await getCartSummary()
return (
<section>
<h2>购物车</h2>
<p>{cart.itemCount} 件商品</p>
<p>合计 {cart.totalLabel}</p>
</section>
最后由 Server Component 同时 import 两边,并把服务器 JSX 交给客户端插槽。
// app/page.tsx
import Cart from './ui/cart'
import Modal from './ui/modal'
export default function Page() {
return (
<main>
<h1>商城首页</h1>
<Modal triggerLabel="查看购物车">
<Cart />
</Modal>
</main>
)

在这个模式里,Cart 会由服务器父组件提前渲染,RSC Payload 记录它的结果和所在位置。弹窗关闭时,客户端只是暂时不显示这段 children;它不是点击后才临时 import 或执行 Cart。
如果数据非常昂贵,而且确实只应在用户点击后获取,需要另外设计导航、Server Function、Route Handler 或客户端数据请求。不要把 children 插槽误当成懒加载接口。
判断组合是否正确,可以检查客户端文件:它应该只知道“这里有一段 ReactNode”,不应该知道那段内容如何访问数据库。服务器父组件才知道两边的具体实现。
从 Server Component 传到 Client Component 的数据,先要通过 React 的序列化规则;通过之后,还要接受一次安全审查。
常用且支持的值包括:
null、undefined 和 bigint。Date、Map、Set、TypedArray 与 ArrayBuffer。use server 定义的 Server Function。常见的不支持值包括:
null 的对象。Symbol.for 注册的 Symbol。因此,“跨边界数据只能是 JSON”并不准确。React 的支持范围比 JSON 更宽;但课程项目仍应优先使用简单、明确的 DTO,因为它更容易审查和长期维护。
// app/page.tsx
import ClientButton from './client-button'
export default function Page() {
function handleClick() {
console.log('clicked')
}
return <ClientButton onClick={handleClick} />
}上面的普通函数无法序列化为客户端事件处理器。按钮的本地事件应该定义在 Client Component 内;需要调用服务器修改数据时,则使用经过授权的 Server Function。use server 标记的是 Server Function,不是 Server Component。
一个 API token 是字符串,当然可以被序列化;密码哈希也是字符串,同样能通过第一关。真正的安全问题在第二关:浏览器是否应该得到它。
下面的做法即使技术上可以序列化,也属于数据泄露。
// 错误:把整条数据库记录交给客户端
const user = await db.user.findUnique({ where: { id } })
return <ProfileEditor user={user} />正确做法是在服务器数据层建立公开 DTO,并执行必要的授权。
// data/get-public-profile.ts
import 'server-only'
import { db } from '@/lib/db'
import { requireViewer } from '@/lib/auth'
export async function getPublicProfile(id: string) {
const viewer = await requireViewer()
const user = await db.user.findUniqueOrThrow({ where: { id } })
return {
id: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
DTO 的意义不只是让 TypeScript 类型变短。它把“允许进入渲染上下文的数据”变成一份明确白名单。

下面的交互工具故意把“能否序列化”和“是否安全公开”分成两块。试着选择 Date、Map、普通函数和类实例,再把 token、邮箱、显示名称放进 DTO,观察两道检查为什么不能合并。
只靠开发者记住每个文件的用途并不可靠。Next.js 支持用 server-only 和 client-only 给模块增加显式护栏。
// lib/catalog-api.ts
import 'server-only'
export async function getCatalog() {
const token = process.env.CATALOG_API_TOKEN
if (!token) {
throw new Error('缺少 CATALOG_API_TOKEN')
}
const response = await fetch('https://internal.example/catalog', {
headers: {
authorization: 'Bearer ' + token,
},
如果 Client Component 直接或间接 import 这个模块,Next.js 会在构建阶段报错。这样错误不会等到生产环境的浏览器请求才暴露。
// lib/saved-theme.ts
import 'client-only'
export function readSavedTheme() {
return localStorage.getItem('theme')
}这个模块不应该被 Server Component import。真正调用它的 Client Component 仍应在事件或 Effect 中访问 localStorage,因为 Client Components 首次可能参与服务器预渲染。
Next.js 会识别这两个标记并提供更明确的错误信息。是否需要把同名包列入项目依赖,可以结合 lint 规则决定;关键是模块顶部的环境声明。
默认情况下,私有环境变量只在服务器环境可用。给变量名加上 NEXT_PUBLIC_ 后,Next.js 会在构建时把它的值内联进发送给浏览器的 JavaScript。
因此下面的变量可以用于公开的分析站点标识:
NEXT_PUBLIC_ANALYTICS_ID=public-site-id但数据库密码、支付密钥、内部 API token 绝不能为了“客户端读取方便”而加这个前缀。前缀不是权限系统,它表达的正是“这个值允许公开”。
Server Component 能直接访问数据库,不代表每次读取都自动安全。它仍然要验证当前用户是谁、是否有权读取目标记录,并尽量返回最小 DTO。
安全边界至少包括三层:
server-only,避免误入客户端图。“代码在服务器运行”只能保护没有被传出去的秘密。一旦把秘密写进 Client Component props、HTML、RSC Payload、公开环境变量或错误信息,它就已经跨出了安全边界。
这两类问题看起来分散,其实都在问同一件事:一段需要客户端能力的代码,应该从哪里接入服务器组件树。
React Context 常用来共享主题、语言和客户端会话状态。createContext 和读取客户端 Context 的 Hook 不支持在 Server Component 中直接使用,因此 Provider 应放在单独的 Client Component 文件。
// app/dashboard/preferences-provider.tsx
'use client'
import {
createContext,
useContext,
useState,
type ReactNode,
} from 'react'
type Density = 'comfortable' | 'compact'
const PreferencesContext = createContext<{
density: Density
toggleDensity: () => void
} | null>(null)
服务器布局可以直接渲染这个 Provider。
// app/dashboard/layout.tsx
import { PreferencesProvider } from './preferences-provider'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <PreferencesProvider>{children}</PreferencesProvider>
}Provider 应尽量靠近真正需要它的子树。只有工作台需要密度设置,就不要把整个根文档都变成客户端 Provider 的范围。
上面的 children 仍可包含 Server Components,但它们已经先在服务器环境中执行,不能调用 usePreferences 读取客户端 Context。能读取该 Context 的是 Provider 下方的 Client Components。
如果 Server Component 需要当前用户或语言,应在服务器侧直接读取请求信息或调用缓存的数据层函数,而不是期待从客户端 Context 反向传回服务器。
有些第三方组件使用了 useState,但包本身没有保留 'use client' 指令。可以在应用内创建一个很薄的客户端入口。
// app/ui/carousel.tsx
'use client'
export { Carousel as default } from 'acme-carousel'Server Component 以后 import 的是我们自己的 Carousel 入口,不需要把整个页面改成客户端组件。
客户端组件首次会参与预渲染。如果第三方包在模块求值阶段立刻访问 window 或 document,仅添加指令仍可能失败。
确实无法预渲染的组件,可以在 Client wrapper 中使用动态导入并关闭 SSR:
// app/ui/browser-map.tsx
'use client'
import dynamic from 'next/dynamic'
const BrowserMap = dynamic(() => import('browser-only-map'), {
ssr: false,
})
export default BrowserMapssr: false 应是针对不兼容库的局部措施,不是普通 Client Component 的默认设置。它会放弃这块区域的首次预渲染,需要同时设计稳定的加载状态和布局尺寸。
Server Component 没有对应指令。App Router 的服务器模块图是默认起点。use server 用于定义可以从客户端发起网络调用的异步 Server Function。
首次加载会预渲染 Client Components 的 HTML。它们仍要下载客户端 JavaScript 并水合,所以必须按浏览器安全模型编写。
只在服务器模块图进入客户端模块图的入口写。入口 import 的依赖自然属于客户端子树。
边界沿 import 图传播。服务器父组件通过 children 或其他 JSX prop 传入的 Server Component 可以在视觉上位于客户端外壳内部。
它可以在构建阶段运行,也可以在请求阶段运行。静态、动态与缓存决策由具体数据依赖决定。
token 和密码哈希都是可序列化字符串。是否允许浏览器看到,必须由授权、DTO 和字段最小化决定。
Client Component 仍可能预渲染。模块加载时直接触碰 window 的库,可能需要 Client wrapper 内的 dynamic 与 ssr: false。
它能减少某些客户端 JavaScript 和客户端数据瀑布,但不会自动修复慢查询、过大的图片、串行请求或不合理缓存。性能结论要结合真实网络和 bundle 测量。
下面是一张常见的全客户端工作台。它在 Effect 中获取首屏数据,搜索、抽屉和推荐列表全部由同一个客户端入口管理。
// app/workbench/page.tsx
'use client'
import { useEffect, useState } from 'react'
export default function WorkbenchPage() {
const [products, setProducts] = useState([])
const [query, setQuery] = useState('')
const [drawerOpen, setDrawerOpen] = useState(false
请完成四项重构:
server-only 的数据层。id、name 和 priceInCents。page.tsx 标成客户端入口。RecommendationList 通过 children 放进客户端 Drawer,不要由 Drawer import 它。写完一张 App Router 页面后,可以按下面的顺序快速对账:
'use client'?use server 错当成 Server Component 指令?如果这些问题都能回答清楚,服务器组件和客户端组件就不再是两个需要死记的名词,而是一套可以检查的模块边界。下一节会在这条边界之上继续讨论数据获取、缓存、静态与动态渲染,以及流式响应。
这里的关键不在文件数量,而在 import 方向:服务器页面知道数据层、客户端入口和服务器推荐列表;ProductFilter 只接收公开 DTO;Drawer 只接收 ReactNode,因此不会把推荐列表的数据模块拖入客户端包。