Next.js App Router + shadcn/ui:服务端与客户端组件混用指南
凌晨三点,盯着屏幕上的报错信息:Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client"。
我已经在 layout.tsx 里加了 "use client",为什么还是报错?
翻了半天文档,才发现问题出在组件导入的边界。App Router 的 Server Components 和 Client Components 分界线,远比我想象的复杂。
这就是很多开发者迁移到 App Router 时遇到的真实场景。框架默认所有组件都是 Server Component,但 UI 库(比如 shadcn/ui)大部分又需要 Client Component。两者的边界该怎么划分?数据怎么流转?性能怎么优化?
这篇文章就是要把这些问题彻底说清楚。
Server Components vs Client Components:根本区别
先说个最基本的点:App Router 下,所有组件默认都是 Server Component。
这意味着什么?你的 page.tsx、layout.tsx 默认都在服务器端渲染,不会向浏览器发送任何 JavaScript 代码。
Server Components 能做什么
Server Components 的核心优势是”离数据更近”:
// app/products/page.tsx - Server Component (默认)
async function ProductsPage() {
// 直接在组件里 await 数据
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 缓存1小时
}).then(res => res.json())
return (
<div>
{products.map(p => (
<div key={p.id}>{p.name} - ${p.price}</div>
))}
</div>
)
}
你看,没有 useEffect,没有 useState,直接 await 就能拿数据。这就是 Server Components 的”async 组件”特性。
适用场景:
- 数据获取(fetch、数据库查询)
- 访问后端专属 API(headers()、cookies())
- 大型依赖库(比如 markdown 解析器 100KB+,用 Server Component 就不用打包给浏览器)
- 敏感信息处理(API key 永远不暴露给前端)
Client Components 能做什么
Client Components 是我们熟悉的”传统 React 组件”。只要文件顶部加上 "use client":
// components/like-button.tsx
'use client'
import { useState } from 'react'
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const [count, setCount] = useState(0)
const handleClick = () => {
setLiked(!liked)
setCount(prev => liked ? prev - 1 : prev + 1)
}
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {count}
</button>
)
}
适用场景:
- 事件处理(onClick、onChange、onSubmit)
- React hooks(useState、useEffect、useRef、useContext)
- 浏览器 API(localStorage、window、document)
- Context Provider
有个反直觉的点:Client Components 也会在服务器端预渲染 HTML。只是后续会在浏览器端 hydrate,恢复交互能力。所以用户首次访问时,依然能看到完整内容,不会”白屏等待 JS 加载”。
核心规则:谁可以导入谁
这部分最容易踩坑。
规则很简单,但很多人记反:
- Server Component 可以导入 Client Component ✅
- Client Component 不能导入 Server Component ❌
- Server Component 可以作为 children 传给 Client Component ✅
第三条有点绕,看代码就明白了:
// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'
export default function Page() {
return (
<ClientContainer>
{/* ServerData 作为 children 传进去 */}
<ServerData />
</ClientContainer>
)
}
// client-container.tsx
'use client'
export function ClientContainer({ children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// server-data.tsx - Server Component
async function ServerData() {
const data = await fetch('/api/data').then(r => r.json())
return <div>{data.title}</div>
}
这个模式很常见:Client Container 负责交互逻辑,Server Data 负责数据获取。两者通过 children 隔离,不直接导入。
shadcn/ui 集成:为什么这么”麻烦”
shadcn/ui 是我最喜欢的 UI 库,但在 App Router 里用它确实需要点技巧。
根本原因是:shadcn/ui 基于 Radix UI,大部分组件用了 React hooks。
比如 Button、Dialog、Dropdown Menu 这些,内部都有 useState 或 useEffect。所以它们必须是 Client Component。
错误示范:在 Server Component 直接用 shadcn/ui
// ❌ 错误:Server Component 导入 Client Component
import { Button } from '@/components/ui/button'
async function ProductPage() {
const product = await fetchProduct()
return (
<div>
<h1>{product.name}</h1>
{/* 这会报错:Button 需要 "use client" */}
<Button onClick={() => addToCart(product.id)}>
Add to Cart
</Button>
</div>
)
}
报错信息:Button 使用了 useState,必须标记 "use client"。
正确方案1:交互部分提取为 Client Component
最常用也最简单的方案:
// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'
async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
return (
<div>
{/* Server Component:负责数据展示 */}
<ProductInfo product={product} />
{/* Client Component:负责交互 */}
<AddToCartButton productId={product.id} />
</div>
)
}
// product-info.tsx - Server Component
export function ProductInfo({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
)
}
// add-to-cart-button.tsx - Client Component
'use client'
import { Button } from '@/components/ui/button'
import { useState } from 'react'
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addToCart(productId)
setLoading(false)
}
return (
<Button onClick={handleAdd} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
)
}
核心思路:把需要交互的部分单独提取成叶子节点,其他部分保持 Server Component。
正确方案2:组合模式(Server 传数据给 Client)
如果 Client Component 需要初始数据:
// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'
async function DashboardPage() {
const users = await fetchUsers() // Server Component 获取数据
return <DataTable data={users} /> // 传给 Client Component
}
// data-table.tsx - Client Component
'use client'
import { Table } from '@/components/ui/table'
import { useState } from 'react'
export function DataTable({ data }) {
const [selectedRows, setSelectedRows] = useState([])
return (
<Table>
{/* shadcn/ui Table 组件 */}
<TableBody>
{data.map(user => (
<TableRow
key={user.id}
selected={selectedRows.includes(user.id)}
onClick={() => toggleSelection(user.id)}
>
<TableCell>{user.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
这样既享受了 Server Component 的数据获取优势,又保留了 Client Component 的交互能力。
Context Provider 怎么放
另一个常见困惑:全局 Context Provider(比如 ThemeProvider、AuthProvider)应该放在哪?
答案是:必须放在 Client Component,但要”越深越好”。
// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* 不要在这里放 Provider */}
{children}
</body>
</html>
)
}
// app/providers.tsx - Client Component
'use client'
import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
)
}
// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'
export default function DashboardLayout({ children }) {
return (
<Providers>
{children}
</Providers>
)
}
为什么要”深”?因为 Provider 会把它包裹的所有组件都变成 Client Component 的子树。如果放在 root layout,整个应用都会被迫客户端渲染。
放在更深的层级(比如某个特定路由的 layout),就能把 Provider 的影响范围最小化。
数据流:Server 到 Client 的传递
Props 是最简单也最可靠的方式:
// Server Component 拿数据
const data = await fetchData()
// 传给 Client Component
<ClientComponent initialData={data} />
但有个性能优化的点:React.cache() 函数。
如果多个 Server Component 需要同一份数据,可以用 cache 避免重复请求:
// lib/get-user.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [id])
})
// app/layout.tsx
async function Layout() {
const user = await getUser('123') // 第一次请求
return <header>{user.name}</header>
}
// app/page.tsx
async function Page() {
const user = await getUser('123') // 相同参数,不会重复请求
return <main>Welcome {user.name}</main>
}
cache 会在单次渲染周期内,自动去重相同参数的调用。
四个最常见的错误
错误1:在高层级滥用 “use client”
// ❌ app/layout.tsx 加了 "use client"
'use client'
export default function Layout({ children }) {
return <div>{children}</div>
}
这会让整个应用的子树都变成 Client Component,丧失 Server Component 的性能优势。
修复:只在真正需要交互的组件添加 "use client",保持它在叶子节点。
错误2:Server Component 里用 hooks
// ❌ Server Component 使用 useState
async function Page() {
const [count, setCount] = useState(0) // 报错!
return <div>{count}</div>
}
修复:提取需要 hooks 的部分为 Client Component。
错误3:Client Component 里用 headers()/cookies()
// ❌ Client Component 使用服务器 API
'use client'
import { headers } from 'next/headers'
function UserProfile() {
const headersList = headers() // 报错!只能在 Server Component 用
return <div>...</div>
}
修复:在 Server Component 获取数据,再传递给 Client Component:
// Server Component 获取 headers
async function Page() {
const userAgent = headers().get('user-agent')
return <UserProfile userAgent={userAgent} />
}
// Client Component 接收数据
'use client'
function UserProfile({ userAgent }) {
return <div>Browser: {userAgent}</div>
}
错误4:第三方组件没标记 “use client”
// ❌ Server Component 导入未标记的第三方组件
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // 报错!AcmeCarousel 内部用了 hooks
}
修复:创建一个 wrapper:
// components/carousel-wrapper.tsx
'use client'
import { AcmeCarousel } from 'acme-carousel'
export function CarouselWrapper(props) {
return <AcmeCarousel {...props} />
}
// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'
async function Page() {
return <CarouselWrapper /> // 正常工作
}
性能优化建议
最后几个实用技巧:
1. Client Components 放叶子节点
这条规则能减少 70% 的客户端 JavaScript。
比如一个产品列表页面:
- 产品网格:Server Component
- 每个产品卡片:Server Component
- 卡片上的数量选择器:Client Component(唯一的交互部分)
2. 用 Suspense 流式渲染
// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'
export default function Page() {
return (
<div>
{/* 先显示骨架屏,数据到了再替换 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
{/* Secondary content 独立流式渲染 */}
<Suspense fallback={<RecSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
用户先看到页面框架,数据再逐步填充。体验比”等待所有数据加载”好得多。
3. fetch 缓存策略
// 静态数据(构建时获取)
await fetch(url, { cache: 'force-cache' })
// ISR:每小时重新验证
await fetch(url, { next: { revalidate: 3600 } })
// 动态数据(每次请求都获取)
await fetch(url, { cache: 'no-store' })
合理选择缓存策略,避免过度动态渲染。
总结
说了这么多,核心就几条:
- 默认用 Server Components,只在需要交互时才用 Client Components
- Server 可以导入 Client,但 Client 不能导入 Server
- 通过 children 或 props 传递数据,保持边界清晰
- “use client” 加在叶子节点,别在高层级滥用
- shadcn/ui 组件单独提取,别混在 Server Component 里
App Router 的 Server/Client 分界线,设计初衷是让开发者”离数据更近,离浏览器更远”。理解了这点,很多困惑就迎刃而解了。
建议你从简单页面开始实践:先写 Server Component 获取数据,再逐步添加交互部分。遇到报错别慌,多半是边界问题——检查一下组件导入关系,很快就能定位。
正确混用 Server 和 Client Components
在 Next.js App Router 项目中集成 shadcn/ui 的最佳实践
⏱️ 预计耗时: 30 分钟
- 1
步骤1: 识别组件类型需求
判断每个组件是否需要交互功能:
• 需要事件处理(onClick、onChange) → Client Component
• 需要 React hooks(useState、useEffect) → Client Component
• 需要浏览器 API(localStorage、window) → Client Component
• 仅数据展示、无交互 → Server Component(默认) - 2
步骤2: 提取交互部分为叶子节点
将需要交互的部分单独提取为 Client Component:
• 创建新文件,顶部添加 'use client'
• 导入 shadcn/ui 组件(Button、Dialog 等)
• 在 Server Component 中导入这个 Client Component
• 通过 props 传递数据 - 3
步骤3: 设计数据流
Server Component 获取数据,传给 Client Component:
• Server Component 使用 async/await 获取数据
• 通过 props 传递给 Client Component
• 多处需要相同数据时,使用 React.cache() 避免重复请求
• 避免在 Client Component 直接使用 headers()/cookies() - 4
步骤4: 放置 Context Provider
Provider 必须是 Client Component,但要放在深层 layout:
• 创建 providers.tsx,标记 'use client'
• 包裹 ThemeProvider、AuthProvider 等
• 在特定路由的 layout.tsx 导入(而非 root layout)
• 最小化 Client Component 子树范围 - 5
步骤5: 验证和优化
检查组件边界是否正确:
• 确保 'use client' 只在叶子节点
• 检查没有 Client Component 导入 Server Component
• 使用 Suspense 包裹异步组件
• 配置合理的 fetch 缓存策略
常见问题
为什么 shadcn/ui 组件必须用 Client Component?
Server Component 和 Client Component 能互相导入吗?
如何避免在多个 Server Component 中重复请求相同数据?
Context Provider 应该放在哪里?
遇到 'useEffect 只能在 Client Component 使用' 的错误怎么办?
如何判断一个组件应该是 Server 还是 Client Component?
系列归属:本文属于 Next.js 完全指南 系列(第 46 篇),如果你在学习 Next.js App Router,可以看看系列的其他文章。关于 shadcn/ui 的更多实战技巧,推荐阅读 Tailwind 与 shadcn/ui 实战指南 系列。
7 分钟阅读 · 发布于: 2026年3月31日 · 修改于: 2026年3月31日
相关文章
Astro + Tailwind:岛屿组件与全局样式不冲突的配置
Astro + Tailwind:岛屿组件与全局样式不冲突的配置
React Compiler + shadcn/ui:自动优化时代的前端开发
React Compiler + shadcn/ui:自动优化时代的前端开发
Nginx 反向代理完全指南:upstream、缓冲与超时

评论
使用 GitHub 账号登录后即可评论