HeroUI v3 在 Next.js App Router 里反复爆炸:server-default 文件的 client-only 陷阱
把 HeroUI v3 迁进 Next.js 15 App Router 项目的时候,我以为只是换个组件库,结果撞上了一个会在不同文件里接连爆炸的坑。第一次爆 layout.tsx,修完以为没事了,转眼 loading.tsx 同款报错,再修再转,not-found.tsx 又来一次——每次看着像独立 bug,实际上是同一颗地雷踩了三遍。
现象
任意从 @heroui/react 导入一个组件(Spinner、Toast、Chip……)到 App Router 的特定文件里,就会得到:
'client-only' cannot be imported from a Server Component module.
It should only be used from a Client Component.
The error was caused by importing '@heroui/react/dist/index.js' in './src/app/.../loading.tsx'.
第一次中招通常在 layout.tsx——因为根布局往往最早引入全局 UI 组件。修完 layout,路由跳转时 loading.tsx 的 Suspense fallback 里同款崩溃。修完 loading,有时候 not-found.tsx 又接着来。
App Router 里有一组"server-default"特殊文件——layout.tsx、page.tsx、loading.tsx、template.tsx、not-found.tsx、default.tsx——它们默认都是 Server Component。只要其中任何一个直接导入了 @heroui/react,就会触发同样的错误。每次只修一个文件是在打地鼠,一次扫全才是解法。
根因
@heroui/react v3 整个库从顶层到每个组件子包都内嵌了 import "client-only"。这个 client-only 包(npm 上就三行代码)的作用是:一旦在 Node.js / RSC 环境里被 import,就立刻抛错。
HeroUI v3 底层是 react-aria-components,而 React Aria 本身就是纯 client 架构——事件系统、焦点管理、WAI-ARIA 状态都依赖浏览器 DOM,根本无法在 server 端运行。所以 HeroUI 把整个包标成 client-only,是有意为之,不是 bug。
问题在于文档没有提示:App Router 的那几个特殊文件名默认是 Server Component,直接往里 import HeroUI 必然触发边界冲突。
值得一提:@mantine/core v7+、framer-motion v11+ 等主流库也有类似的 client-only 标记,将来换库同样会踩同款坑。
正解
两个修法,按文件是否需要保持 Server Component 来选。
Pattern A — 直接给文件加 'use client'(最省事)
适用于 loading.tsx、template.tsx、not-found.tsx、大多数纯展示 page.tsx。这些文件转成 Client Component 没有副作用:
'use client'
import { Spinner } from '@heroui/react'
export default function Loading() {
return <Spinner size="lg" />
}注意:'use client' 必须是文件的第一行(BOM 和空行之后),放在注释之后会被部分 SWC 版本静默忽略。
Pattern B — 文件保持 Server,把 HeroUI 抽到 client island 子组件
适用于 layout.tsx——根布局通常有 export const metadata、服务端数据拉取、模块顶层副作用,不适合整体转 client:
// src/app/heroui-providers.tsx — 新建,标 client
'use client'
import { HeroUIProvider } from '@heroui/react'
export default function HeroUIProviders({ children }: { children: React.ReactNode }) {
return <HeroUIProvider>{children}</HeroUIProvider>
}// src/app/layout.tsx — 保持 server,不直接引 @heroui/react
import type { Metadata } from 'next'
import HeroUIProviders from './heroui-providers'
export const metadata: Metadata = { title: '...' }
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<HeroUIProviders>{children}</HeroUIProviders>
</body>
</html>
)
}修完任意一个 server-default 文件后,立即跑这段 sweep,把所有潜在冲突点一次性找出来:
for f in $(find src/app -type f \( \
-name "loading.tsx" -o -name "layout.tsx" -o -name "template.tsx" \
-o -name "not-found.tsx" -o -name "default.tsx" -o -name "page.tsx" \
\)); do
has_client=$(head -3 "$f" | grep -c "use client" || true)
has_heroui=$(grep -c "@heroui/react" "$f" || true)
if [ "$has_heroui" -gt 0 ] && [ "$has_client" -eq 0 ]; then
echo "BAD: $f"
fi
doneroute.ts 和 middleware.ts 不能加 'use client',如果扫出来有 HeroUI import 要直接删除——路由处理器不渲染 UI。
npx tsc --noEmit 对这类错误无感——TS 不理解 RSC 边界。只有 next build 或 next dev 才会触发 Webpack 的 RSC graph 检查。所以别以为类型通过就万事大吉,必须实际跑起来验。
一句话外卖
遇到
client-only cannot be imported from a Server Component,修完一处立刻 grep 全仓同类文件——这类错误从不单独出行。