返回博客

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,为什么报这个?

根因

两个错误各有各的根因,叠在一起才产生这种「修了一个又来一个」的连环感。

错误 1useMDXComponents 是 Next.js 在服务端编译 MDX 时调用的函数。只要文件头部有 "use client",这个函数就变成 client 函数,服务端调用时直接报错。这个文件绝对不能"use client"

错误 2output: "export" 会在每条路由上隐式设置 dynamic = "error",意味着任何产生动态依赖的 API 都会让构建失败。而 usePathname() 内部会访问 searchParams,这正是一个动态依赖——Next.js 的静态分析一眼就能认出来,然后拒绝预渲染。

坑:usePathname 在 output:export 里是动态依赖炸弹

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 壳 + 叶岛下沉

  1. mdx-components.tsx 保持 Server Component。 useMDXComponents 留在服务端,wrapper 也是 Server Component,直接把 MDX 的 children 透传渲染。

  2. 把所有 client 需求拆成独立叶岛组件,每个叶岛自给自足,不包裹 children

    • ArticleMeta:显示阅读时长 + 日期
    • ArticleNav:上一篇 / 下一篇
  3. window.location.pathname 替换 usePathnameuseEffect(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;
}
正解:Server 壳 + client 叶岛 + window.location

useMDXComponents 文件不加 "use client";wrapper 是 Server Component;所有交互需求下沉到叶岛组件(无 children 包裹);路由信息在 useEffect 里读 window.location.pathname,完全绕开 Next.js 动态分析。

验证

构建成功后每条路由应显示 ○ (Static)● SSG,不再出现 dynamic bailout。浏览器打开后叶岛在 hydration 后渲染,无控制台报错。

延伸

同样的坑会出现在 framer-motion / motion 里:AnimatePresencemotion.* 若包裹服务端 children 并且内部依赖路由状态,同样会触发 bailout。解法一致:把动画移到叶级元素上,不让动画组件包 children。

一句话外卖

output: "export" 下,usePathname 是隐形地雷;要读路由,在 useEffect 里读 window.location,要读文章字数,在 mount 后扫 DOM——把所有浏览器依赖下沉到叶岛,Server 壳保持干净。