Next.js + next-mdx-remote 在App Router中使用指南——SSR+fs篇

1,132 阅读11分钟

一、概述

本文主要通过实际使用案例讨论next-mdx-remote在Nextjs的App Router中的SSR用法,官方教程中没有明确提到在App Router中的用法(功能上支持App Router)。

由于Next.js在处理next-mdx-remote的代码分割client和server代码时容易出现预期之外的情况,从而引发报错。特别是需要Node.js环境下的代码(fs文件模块等)。

二、介绍

next-mdx-remote是一款可以加载远程MDX资源的工具,非常适合加载网络资源,动态资源等。

配合Next的动态路由可以非常方便的实现资源映射。

仓库链接☞ github.com/hashicorp/n…

对于MDX.js,官方介绍如下:

MDX 是一种书写格式,允许你在 Markdown 文档中无缝地插入 JSX 代码。 你还可以导入(import)组件,例如交互式图表或弹框,并将它们嵌入到 你所书写的内容当中。 这让利用组件来编写较长的内容成为了一场革命。 🚀

它的特点在于可以在markdown文件中嵌入JSX组件,并且将md/mdx文档渲染为JSX组件。

image.png

具体可以查看☞MDX官方文档

如需在Next.js项目中使用MDX,可参考: ☞ Next.js官方文档中的配置及其方法

三、示例

需求

通过访问 /docs/* 解析动态路由链接获取项目内public/docs目录下的md/mdx资源

思路

通过 fs 文件模块读取映射路由对应的MDX资源

实现

为了减少使用过程中的坑和一些问题,这里从零开始实现此需求。

1. 初始化项目

npx create-next-app@latest remote-mdx-demo

2. 配置mdx

安装必须的mdx依赖

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

添加mdx解析配置

// next.config.mjs
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
    pageExtensions: ['md', 'mdx', 'tsx', 'ts', 'jsx', 'js']
}

const withMDX = createMDX({
    extension: /\.mdx?$/,
    // Add markdown plugins here, as desired
})
 
export default withMDX(nextConfig)

添加mdx-components组件(需要在和app文件夹在同一级目录,src/app模式对应src/mdx-components.tsx,app在根目录)

// mdx-components.tsx
import type { MDXComponents } from 'mdx/types'
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
  }
}

这里定义了一个react的hooks,只要next.config.mjs配置正确和mdx-components.tsx目录正确,那么项目则会自动使用useMDXComponents hook。

MDX效果预览

移除部分next.js默认的css样式:移除globals.css 文件的以下内容

/* app/globals.css */
body {
  color: rgb(var(--foreground-rgb));
  background: linear-gradient(
      to bottom,
      transparent,
      rgb(var(--background-end-rgb))
    )
    rgb(var(--background-start-rgb));
}

@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
}

更改app/page.tsx名称为app/page.mdx,并替换内容为

## 欢迎使用Next.js + MDX.js

运行命令

npm run dev

并在浏览器中打开 localhost:3000 预览mdx功能是否正常

功能正常预览:

image.png

3. 配置next-mdx-remote

安装next-mdx-remote依赖

npm install next-mdx-remote

4. 添加动态路由

app目录下创建 docs/[...MDXPath]/page.tsx文件,并添加以下内容

// app/docs/[...MDXPath]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { readMDXFile } from '@/lib/mdx/read'

export default async function Page({ params }: { params: { MDXPath: string[] } }) {
    const { source } = await readMDXFile(params.MDXPath)
    return <MDXRemote source={source} />
}

这里MDXRemote使用服务器组件(从"next-mdx-remote/rsc"模块导入) MDXRemote组件作为服务器组件,其source属性接受的参数较多,这里是使用fs读取,所以仅展示string用法

image.png

这里创建一个readMDXFile方法用于解析动态路由参数以提取对应的MDX文档内容

创建lib/mdx/read.ts文件并添加内容

// lib/mdx/read.ts
"use server"

import fs from "fs"
import path from "path"
import { MDXConfig } from "."

/**
 * 根据传入的MDXPath和extensions参数,获取mdx数据
 * @param MDXPath 字符串数组或者字符串
 * @param extensions 扩展名数组,默认为 MDXConfig.extensions
 * @returns mdx原始数据(string) 和 error
 */
export async function readMDXFile(
    MDXPath: string[] | string,
    extensions: string[] = MDXConfig.extensions,
): Promise<{ source: string | undefined; error: Error | undefined }> {
    try {
        // 将MDXPath数组转换为完整路径
        const fullMDXPath = Array.isArray(MDXPath)
            ? MDXPath.map((p) => p.trim()).join("/")
            : MDXPath.trim()
        // 根据MDXPath和extensions,生成路径数组
        const paths = extensions.map((extension) =>
            path.join(MDXConfig.DOCS_PATH, `${fullMDXPath}.${extension}`),
        )
        // 读取第一个存在的文件,不存在则抛出异常
        const file = await readFirstExistingFileAsync(paths)
        return { source: file, error: undefined }
    } catch (err: any) {
        const error = new Error(
            `Failed to load MDX: ${err?.message ? err.message : err}`,
        )
        return { source: undefined, error }
    }
}

/**
 * 异步获取列表中第一个存在的文件
 *
 * @param fileNames 文件名数组
 * @returns 第一个存在的文件内容 如果读取文件出错则抛出异常,如果全部无效则返回null
 */
export async function readFirstExistingFileAsync(
    fileNames: string[],
): Promise<string | undefined> {
    for (const fileName of fileNames) {
        try {
            // 如果文件不存在,跳过该文件
            if (!fs.existsSync(fileName)) continue
            // 读取文件内容
            const fileContent = await fs.promises.readFile(fileName)
            // 返回文件内容
            return fileContent.toString("utf-8")
        } catch (error: any) {
            // 如果错误不是 ENOENT(文件不存在),打印错误信息
            if (error?.code !== "ENOENT") {
                console.error(`Error reading file "${fileName}":`, error)
            }
            throw error
        }
    }
    return undefined
}

创建lib/mdx/index.ts文件并添加内容

// lib/mdx/index.ts
export const MDXConfig = {
    extensions: ["md", "mdx"],
    DOCS_PATH: "public/docs",
    MENU_PATH: "public/menu.json",
}

其中MDXConfig具体字段:

字段名介绍
DOCS_PATH文档资源根目录(用于动态路由映射的资源位置)
MENU_PATH文档资源结构树存储位置(后续使用)

这里的配置内容可以根据需求更改,后续示例会以该配置进行讲解

此时你可能注意到readMDXFile可能会返回undefined和error,此时需要处理页面状态边界

// app/docs/[...MDXPath]/page.tsx

// ......

export default async function Page({ params }: { params: { MDXPath: string[] } }) {
    const { source, error } = await readMDXFile(params.MDXPath)
    if (error) return <div>Load error</div>
    if (source === undefined) return <div>file does not unexist or is corrupt</div>
    return <MDXRemote source={source} />
}

上面代码为页面添加error边界和undefined边界这样当无效路径和服务器加载出错分别会表现为两种不同状态

5. 添加md/mdx资源

public目录下创建docs目录

新增public/docs/quickstart.mdx文件并添加内容

## 🚀 快速开始
运行命令

```cmd
npm run dev
```

打开浏览器,访问 [http://localhost:3000](http://localhost:3000)

新增public/docs/introduction.md文件并添加内容

## 介绍

这是一个Next.js + MDX.js + next-mdx-remote 的demo项目

预览效果

运行命令

npm run dev

此时在浏览器打开 localhost:3000/docs/quickstart

此时可以看到页面已经动态加载了quickstart.mdx文档的内容,预期效果如下:

image.png

此时再尝试加载instruction.md文档,输入链接 localhost:3000/docs/introduction 跳转

预期效果:

image.png

🎉🎉至此,需求基本已经达成,mdx和md文件都能够正常进行动态解析

6. 多层嵌套目录

由于app/docs目录下的动态路由是[...MDXPath],默认支持多层路由

同理在readMDXFile方法中参数MDXPath也是支持多层路由

// lib/mdx/read.ts
// ...
export async function readMDXFile(
    MDXPath: string[] | string,
    extensions: string[] = MDXConfig.extensions,
): Promise<{ source: string | undefined; error: Error | undefined }> {
   // ...
}

所以我们仅需要在资源目录下添加多层目录,再通过浏览器访问动态路由即可

多层目录示例

添加public\docs\sub\1.md文件并新增内容

当前路径为/docs/sub/1.md

添加public\docs\sub\sub1\2.md文件并新增内容

当前路径为/docs/sub/sub1/2.md

此时打开浏览器并跳转链接 localhost:3000/docs/sub/1 效果预览

image.png

跳转 localhost:3000/docs/sub/sub1/2 效果预览

image.png

🎉🎉至此,需求完全已经达成,通过访问 app/docs/* 即可映射访问 public/docs/* 的资源内容

如有兴趣,有继续阅读后续内容

7. 动态路由优化

在实际运行时,可能会发现当浏览器访问不存在的动态路由时(比如/docs/hello),预览如下

image.png

虽然我们在代码中处理了不存在此映射资源的情况,但项目依旧会调用readMDXFile尝试读取该路径内容。这样依旧会消耗部分性能。

// app/docs/[...MDXPath]/page.tsx
// ...
export default async function Page({ params }: { params: { MDXPath: string[] } }) {
const { source, error } = await readMDXFile(params.MDXPath)
// ...
if (source === undefined) return <div>file does not unexist or is corrupt</div>
// ...
}

为了让 app/docs动态路由和 public/docs 目录路径保持一致

这里使用Next App Router中的 generateStaticParams方法生成静态路由参数,这样当访问未生成的动态路由时则可以进行限制。

// app/docs/[...MDXPath]/page.tsx
// ...
import { readMDXFile,extractMDXMenuPaths } from '@/lib/mdx/read'

export const dynamicParams = false

export async function generateStaticParams() {
    const paths: string[] = await extractMDXMenuPaths()
    // 由于MDXPath参数类型为string[],所以这里需要转化路径
    const staticPaths = paths.map(path => ({ MDXPath: path.replace(/^\/docs\//, "").split("/") }))
    return staticPaths
}

// ...

这里dynamicParams为false代表禁止访问未通过generateStaticParams生成的路由

而extractMDXMenuPaths方法则是遍历public/docs目录并生成一组路径

此时app/docs路由现在和public/docs目录一一对应

extractMDXMenuPaths实现

// lib/mdx/read.ts
"use server"

// ...

/**
 * 从MDX menu树形结构中抽取所有叶子节点 可自定义返回内容
 *
 *@param callback 回调函数,用于处理叶子节点,返回自定义内容
 * @returns 所有叶子节点的路径数组
 */
async function extractMDXMenu<T>(callback: (node: TreeNode<NavLink>) => T): Promise<T[]> {
    const menu: NavLink = await readMDXMenu()
    const results: T[] = []

    function traverse(node: TreeNode<NavLink>) {
        if (!!node.children?.length) {
            for (const child of node.children) {
                traverse(child)
            }
        } else {
            const res = callback(node)
            results.push(res)
        }
    }

    traverse(menu)
    return results
}

/**
 * 提取MDX menu中的所有path路径
 *
 * @returns 路径数组
 */
export async function extractMDXMenuPaths(): Promise<string[]> {
    return await extractMDXMenu((node) => node.href)
}

/**
 * 从动态生成的MDX menu.json中提取menu数据
 *
 * @returns menu数据
 */
export async function readMDXMenu(): Promise<NavLink> {
    const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
    const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
    return menu
}

这里为了便于管理和使用Menu,可以通过脚本遍历public/docs目录,生成一个node树数据(json),在使用menu时则通过读取这个json数据,而不必每次都遍历目录而带来不必要的性能消耗。

新增lib/mdx/build.ts文件,新增内容

// lib/mdx/build.ts
import path from "path"
import fs from "fs"
import { MDXConfig } from "."

export type TreeNode<T> = T & { children?: TreeNode<T>[] };

/**
 * 异步遍历指定目录,构建树形结构,并允许自定义处理逻辑。
 * 
 * @param dir 目标目录的路径
 * @param handleItem 处理每个目录项的回调函数,返回处理后的 TreeNode<T>
 * @returns 树形结构的数据,类型为 TreeNode<T>
 */
export async function buildMDXMenu<T>(
    dir: string,
    handleItem: (itemPath: string) => TreeNode<T>
): Promise<TreeNode<T>> {
    
    removeMDXMenu()

    const node = handleItem(dir);
    const entries = await fs.promises.readdir(dir, { withFileTypes: true });

    if (!node.children) {
        node.children = [];
    }

    for (const entry of entries) {
        const itemPath = path.join(dir, entry.name);
        const childNode = handleItem(itemPath);

        if (entry.isDirectory()) {
            const subTree = await buildMDXMenu<T>(itemPath, handleItem);
            node.children!.push(subTree);
        } else {
            node.children!.push(childNode);
        }
    }

    return node;
}

export function removeMDXMenu() {
    const isExist = fs.existsSync(MDXConfig.MENU_PATH)
    if (isExist) {
        fs.rmSync(MDXConfig.MENU_PATH)
    }
}

这里实现了public/docs目录资源的遍历,但是抽象了每一项生成的内容,可自定义每一个节点的内容

添加生成目录的脚本

新增scripts/mdx.ts文件并新增内容

// scripts/mdx.ts
import { buildMDXMenu, TreeNode } from "@/lib/mdx/build"
import fs from "fs"
import { MDXConfig } from "@/lib/mdx"
import path from 'path'
import { NavLink } from "@/types/link"

export async function generateMDXMenu() {
    const { DOCS_PATH, MENU_PATH } = MDXConfig

    const handleItemToTreeNode = (itemPath: string): TreeNode<NavLink> => {
        const label = path.parse(itemPath).name;
        const normalizedPath = itemPath.replace(/\\/g, '/')
        const href = normalizedPath.replace(DOCS_PATH.split("/").slice(0, -1).join("/"), '').split(".")[0]
        return {
            label,
            href: href,
            children: undefined,
        };
    };

    const tree = await buildMDXMenu<NavLink>(DOCS_PATH, handleItemToTreeNode);

    fs.writeFileSync(MENU_PATH, JSON.stringify(tree, null, 2))
}

generateMDXMenu().catch(console.error)

buildMDXMenu接收一个文件夹路径和一个节点处理回调 这里我通过回调将资源路径转化为了可访问的href(例如"public/docs/quickstart.mdx"转化为"/docs/quickstart"),便于导航使用

此处的NavLink类型为

// types/link.d.ts
export interface NavLink {
    label: string;
    href: string
    children?: NavLink[];
}

本地或者全局安装tsx,便于运行ts后缀的文件(ts-node亦可,个人感觉tsx更加稳定)

在package.json中添加script

{
    // ...
  "scripts": {
    "dev": "next dev",
    "build": "npm run prepare && next build",
    "start": "next start",
    "lint": "next lint",
    "prepare": "tsx ./scripts/mdx.ts"
  },
  // ...
}

此时仅在build命令触发时会自动构建public/docs目录的,其他情况需要手动运行prepare命令构建

如果你存在其他需要构建的脚本,可选择合并多条脚本命令或者在统一的文件中调用脚本

例如集中在scripts/prepare.ts中调用需要处理的脚本

移除scripts/mdx.ts中调用

// scripts/mdx.ts

// ...

// 删除以下代码
generateMDXMenu().catch(console.error)

新增scripts/prepare.ts文件或者其他集中脚本调用文件

// scripts/prepare.ts
import { generateMDXMenu } from "./mdx"

// 项目启动前执行内容
async function main(){
    // other scripts ...
    await generateMDXMenu()
}

main().catch(console.error);

修改package.json中的prepare script命令

{
 // ...
 
 "scripts": {
    // ...
    "prepare": "tsx ./scripts/prepare.ts"
  },
 // ...
}

运行命令

npm run prepare

此时将会输出docs文档资源结构,具体路径根据lib\mdx\index.ts文件中的MDXConfig.MENU_PATH的值决定

此处为目录路径定义为public/menu.json,找到对应路径,此时文档树结构为

{
  "label": "docs",
  "href": "/docs",
  "children": [
    {
      "label": "introduction",
      "href": "/docs/introduction"
    },
    {
      "label": "quickstart",
      "href": "/docs/quickstart"
    },
    {
      "label": "sub",
      "href": "/docs/sub",
      "children": [
        {
          "label": "1",
          "href": "/docs/sub/1"
        },
        {
          "label": "sub1",
          "href": "/docs/sub/sub1",
          "children": [
            {
              "label": "2",
              "href": "/docs/sub/sub1/2"
            }
          ]
        }
      ]
    }
  ]
}

如需调整每一项节点的结构,可在scripts\mdx.ts文件内generateMDXMenu方法内部的handleItemToTreeNode自定义结构。

例如根据文件路径读取md/mdx的frontmatter中的字段作为目录节点的字段

这里通过读取md/mdx的frontmatter的icon字段作为示例

// scripts/mdx.ts
// ...
import grayMatter from "gray-matter"

export async function generateMDXMenu() {
    // ...

    const handleItemToTreeNode = (itemPath: string): TreeNode<NavLink> => {
        const label = path.parse(itemPath).name;
        const normalizedPath = itemPath.replace(/\\/g, '/')
        const href = normalizedPath.replace(DOCS_PATH.split("/").slice(0, -1).join("/"), '').split(".")[0]
        const item: NavLink = {
            label,
            href: href,
            children: undefined,
        };
        const isFile = fs.statSync(itemPath).isFile()

        if (isFile) {
            const source = fs.readFileSync(itemPath, "utf-8")
            if (!source) throw new Error("source load failed")
            const { data: frontmatter } = grayMatter(source!)
            if (frontmatter?.icon !== undefined) {
                item.icon = frontmatter.icon as string
            }
        }

        return item
    };
    // ...
}

对部分文档新增frontmatter数据测试效果

打开public/docs/quickstart.mdx,新增frontmatter数据

---
title: 快速开始
icon: 🚀
---

 // ...

打开public/docs/introduction.md,新增frontmatter数据

---
title: 介绍
icon: 👋
---
// ...

运行命令

npm run prepare

打开menu.json文件查看效果

image.png

此时自定义生成目录节点结构也已经符合预期

回到lib\mdx\read.ts文件下

// lib\mdx\read.ts
// ...
export async function readMDXMenu(): Promise<NavLink> {
    const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
    const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
    return menu
}
// ..

此函数已在前文声明,它的作用是读取构建的menu.json数据

回到app\docs[...MDXPath]\page.tsx文件中

// app/docs/[...MDXPath]/page.tsx

// ...
export const dynamicParams = false

export async function generateStaticParams() {
    const paths: string[] = await extractMDXMenuPaths()
    // 由于MDXPath参数类型为string[],所以这里需要转化路径
    const staticPaths = paths.map(path => ({ MDXPath: path.replace(/^\/docs\//, "").split("/") }))
    return staticPaths
}

// ...

此时就容易理解许多,逻辑就是通过解析json数据来提取path路径来一一映射文档资源

8. 动态导航目录构建

前文中已经实现了遍历public/docs目录构建树形结构,此时通过直接读取json数据即可实现

新增文件components\docs\Aside.tsx

import { cn } from "@/lib/utils"
import { readMDXMenu } from "@/lib/mdx/read"
import { AsideMenuItem } from "./AsideMenuItem"

export async function DocsAside({
    className
}: {
    className?: string
}) {
    const menu = (await readMDXMenu()).children!
    return (
        <aside className={cn("sticky w-60 h-full px-4 py-2 bg-white rounded-r-md", className)}>
            <menu className="w-full space-y-2">
                {menu.map((item) => (
                    <AsideMenuItem key={item.label + item.href} item={item} />
                ))}
            </menu>
        </aside>
    )
}

这里的readMDXMenu方法在前文中也已经声明,直接使用即可

// lib/mdx/read.ts

// ...
/**
 * 从动态生成的MDX menu.json中提取menu数据
 *
 * @returns menu数据
 */
export async function readMDXMenu(): Promise<NavLink> {
    const menuFile = fs.readFileSync(MDXConfig.MENU_PATH)
    const menu = JSON.parse(menuFile.toString("utf-8")) as NavLink
    return menu
}
// ...

添加AsideMenuItem组件,新增文件components\docs\AsideMenuItem.tsx

// components\docs\AsideMenuItem.tsx
"use client"

import { NavLink } from "@/types/link"
import { useState } from "react"
import { usePathname, useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"

export function AsideMenuItem({
    item
}: {
    item: NavLink
}) {
    const pathname = usePathname()
    const router = useRouter()
    const [open, setOpen] = useState<boolean>(false)
    const subMenue = !!item?.children?.length ? (
        item.children.map((subItem) => (
            <AsideMenuItem key={subItem.label + subItem.href} item={subItem} />
        ))
    ) : null
    const clickHandler = () => {
        if (!!item?.children?.length) {
            setOpen((prev) => !prev)
            return
        }
        router.push(item.href)
    }
    return (
        <div className="space-y-2">
            <div
                className={cn("p-2 rounded active:bg-neutral-200 hover:bg-neutral-200 flex flex-row relative select-none cursor-pointer", { "bg-neutral-200": pathname === item.href })}
                onClick={clickHandler}
            >
                {item.label}
                {subMenue && <ChevronDown
                    className={cn("text-input justify-self-end absolute right-4", {
                        "-rotate-90": open,
                    })}
                />}
            </div>
            {subMenue && (
                <div className={cn("pl-4 space-y-2", { "hidden": !open })}>
                    {subMenue}
                </div>
            )}
        </div>
    )
}

这里通过逻辑判断是否跳转,如果当前节点存在子节点说明当前节点是一个目录,点击时则展开/关闭目录,而不是跳转链接

(部分依赖及其方法未提到,需要手动安装,也可查看文末源码)

将侧边栏组件添加到页面

打开并修改app\layout.tsx内容

// app\layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { DocsAside } from "@/components/docs/Aside";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <div className="w-screen h-screen flex">
          <DocsAside />
          <main className="flex-1">
            {children}
          </main>
        </div>
      </body>
    </html>
  );
}

运行项目

npm run prepare
npm run dev

打开链接 localhost:3000/ 预览效果

image.png

使用侧边栏导航

点击文档节点(跳转对应资源) image.png 点击目录节点(打开/关闭子目录) image.png

可以发现md/mdx将frontmatter内容一并放入到了页面内容中,而不是单独解析,可对MDXRemote稍作调整

打开app\docs[...MDXPath]\page.tsx文件,并对MDXRemote添加参数

// app\docs\[...MDXPath]\page.tsx

//..

export default async function Page({ params }: { params: { MDXPath: string[] } }) {
    // ... 
    return <MDXRemote source={source} options={{ parseFrontmatter: true }} />
}

添加options参数,并设置解析frontmatter

回到浏览器,再次查看效果

image.png

image.png

image.png

可以看到,导航和页面资源加载已经符合预期

🎉🎉🎉 至此,本示例教程已经结束,感兴趣的朋友可以参考源码

友情链接:

源码:github.com/SmeLros/nex…