Next.js 静态导出 + MDX 阅读体验踩坑:usePathname 让 output:export 全面崩溃
给静态博客(output: "export" + @next/mdx)加一些阅读增强功能,需求很朴素:文章顶部显示预计阅读时长,底部有上一篇 / 下一篇导航。实现起来似乎只需要在 MDX wrapper 里读一下当前路由、扫一下文章字数。
结果踩进两个连环坑,错误信息都指向「看似无关」的地方,排查了好一阵才想清楚根因。
现象
第一步:把 mdx-components.tsx 标记为 "use client",这样 wrapper 组件里就能用 usePathname。结果 next build 立刻炸:
Attempted to call useMDXComponents() from the server
but useMDXComponents is on the client.
It's not possible to invoke a client function from the server...
第二步:把 "use client" 从文件顶层去掉,改为只让 wrapper 组件是 client component,useMDXComponents 本身保持 server。构建通过了,但开发模式和生产构建又冒出另一个错:
Route /blog/<slug> with `dynamic = "error"` couldn't be rendered statically
because it used `searchParams.then`.
奇怪——代码里根本没有碰 searchParams,为什么报这个?
根因
两个错误各有各的根因,叠在一起才产生这种「修了一个又来一个」的连环感。
错误 1:useMDXComponents 是 Next.js 在服务端编译 MDX 时调用的函数。只要文件头部有 "use client",这个函数就变成 client 函数,服务端调用时直接报错。这个文件绝对不能加 "use client"。
错误 2:output: "export" 会在每条路由上隐式设置 dynamic = "error",意味着任何产生动态依赖的 API 都会让构建失败。而 usePathname() 内部会访问 searchParams,这正是一个动态依赖——Next.js 的静态分析一眼就能认出来,然后拒绝预渲染。
output: "export" 隐式将所有路由设为 dynamic = "error"。usePathname() 内部触及 searchParams,会被静态分析识别为动态依赖,构建直接拒绝——就算你自己从没写过 searchParams。
根因拆解:Server Component 的 children 为什么能穿透 client 边界
MDX 编译出来的内容是服务端渲染的,但 MDX wrapper 本身理论上可以是 client component——关键在于 children 是 React 的特殊 prop,它以 RSC payload(序列化后的服务端树)的形式传入,client wrapper 可以直接把它渲染出来,不需要 wrapper 本身是 Server Component。
这个机制是「客户端叶岛(client-leaf-island)」模式的基础:Server 壳 + Client 叶,壳只负责组合,叶只负责交互,两者通过 children 和兄弟节点组装,互不污染。
正解:Server 壳 + 叶岛下沉
-
mdx-components.tsx保持 Server Component。useMDXComponents留在服务端,wrapper 也是 Server Component,直接把 MDX 的children透传渲染。 -
把所有 client 需求拆成独立叶岛组件,每个叶岛自给自足,不包裹
children:ArticleMeta:显示阅读时长 + 日期ArticleNav:上一篇 / 下一篇
-
用
window.location.pathname替换usePathname。 在useEffect(mount 时)里读window.location,这是纯浏览器 API,不参与 Next.js 的静态/动态分析,不会触发任何 bailout。
// mdx-components.tsx — 保持 Server Component,不加 "use client"
import { ArticleMeta } from "./app/_components/article-meta"; // client 叶岛
import { ArticleNav } from "./app/_components/article-nav"; // client 叶岛
function ArticleWrapper({ children }: { children: React.ReactNode }) {
return (
<main>
<ArticleMeta /> {/* client 叶,读路由、算字数 */}
<article>{children}</article>
<ArticleNav /> {/* client 叶,上下篇 */}
</main>
);
}
export function useMDXComponents(c: any) {
return { wrapper: ArticleWrapper, ...c };
}// article-meta.tsx — "use client" 叶岛
"use client";
import { useState, useEffect } from "react";
export function ArticleMeta() {
const [slug, setSlug] = useState("");
const [readMin, setReadMin] = useState<number | null>(null);
useEffect(() => {
// window.location 不触发 dynamic 依赖,output:export 安全
const s = window.location.pathname.split("/").filter(Boolean).pop() ?? "";
setSlug(s);
const chars = (document.querySelector("article")?.textContent ?? "")
.replace(/\s/g, "").length;
setReadMin(chars > 0 ? Math.max(1, Math.round(chars / 350)) : null);
}, []);
// 根据 slug 从 posts 列表找元数据
const post = posts.find((p) => p.slug === slug);
return post ? <div>{post.date} · 约 {readMin} 分钟</div> : null;
}useMDXComponents 文件不加 "use client";wrapper 是 Server Component;所有交互需求下沉到叶岛组件(无 children 包裹);路由信息在 useEffect 里读 window.location.pathname,完全绕开 Next.js 动态分析。
验证
构建成功后每条路由应显示 ○ (Static) 或 ● SSG,不再出现 dynamic bailout。浏览器打开后叶岛在 hydration 后渲染,无控制台报错。
延伸
同样的坑会出现在 framer-motion / motion 里:AnimatePresence 或 motion.* 若包裹服务端 children 并且内部依赖路由状态,同样会触发 bailout。解法一致:把动画移到叶级元素上,不让动画组件包 children。
一句话外卖
output: "export"下,usePathname是隐形地雷;要读路由,在useEffect里读window.location,要读文章字数,在 mount 后扫 DOM——把所有浏览器依赖下沉到叶岛,Server 壳保持干净。