返回博客

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 导入一个组件(SpinnerToastChip……)到 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 又接着来。

坑:修了 layout.tsx 以为没事,其实还有一批文件在等着

App Router 里有一组"server-default"特殊文件——layout.tsxpage.tsxloading.tsxtemplate.tsxnot-found.tsxdefault.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.tsxtemplate.tsxnot-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
done

route.tsmiddleware.ts 不能加 'use client',如果扫出来有 HeroUI import 要直接删除——路由处理器不渲染 UI。

小提示:TypeScript 不会提前告警

npx tsc --noEmit 对这类错误无感——TS 不理解 RSC 边界。只有 next buildnext dev 才会触发 Webpack 的 RSC graph 检查。所以别以为类型通过就万事大吉,必须实际跑起来验。

一句话外卖

遇到 client-only cannot be imported from a Server Component,修完一处立刻 grep 全仓同类文件——这类错误从不单独出行。