NextJS 中创建 MDX 博客

在本文中,我们将基于Next.js(v13+)分别介绍三种搭建MDX博客应用的方法,分别是@next/mdxnext-mdx-remotecontentlayer他们有各自的优缺点,可以根据自身情况选择使用哪一种方式。

当然,在这里更推荐使用 Contentlayer 的方式,因为他更轻量、更简单、高性能等优点。以下为三种方式差异,可以根据自身情况,自由选择,接下来我们也将分别介绍三种方式的搭建流程。

名称 差异描述
[@next/mdx](#名称 差异描述 @next/mdx Next.js官方提供的markdown 和 MDX解决方案,它从本地文件获取数据,允许您直接在/pages或/app目录中创建带有扩展名.mdx的页面。对于简单内容页面来说相对实用。 next-mdx-remote 不处理从源加载内容,无论是本地还是远程,因此需要我们自己编写代码实现,但也因此相对灵活,在处理过程中需要配合相关插件来实现内容转换处理,如:gray-matter等。 contentlayer 具有重量轻,易于使用、 出色的开发体验以及快速的构建能力和高性能页面的优点的。它从源文件加载内容,并自动生成 TypeScript 类型定义,以确保正在处理的内容符合您期望的形状。 "#@next/mdx") Next.js官方提供的markdown 和 MDX解决方案,它从本地文件获取数据,允许您直接在/pages/app目录中创建带有扩展名.mdx的页面。对于简单内容页面来说相对实用。
[next-mdx-remote](#名称 差异描述 @next/mdx Next.js官方提供的markdown 和 MDX解决方案,它从本地文件获取数据,允许您直接在/pages或/app目录中创建带有扩展名.mdx的页面。对于简单内容页面来说相对实用。 next-mdx-remote 不处理从源加载内容,无论是本地还是远程,因此需要我们自己编写代码实现,但也因此相对灵活,在处理过程中需要配合相关插件来实现内容转换处理,如:gray-matter等。 contentlayer 具有重量轻,易于使用、 出色的开发体验以及快速的构建能力和高性能页面的优点的。它从源文件加载内容,并自动生成 TypeScript 类型定义,以确保正在处理的内容符合您期望的形状。 "#next-mdx-remote") 不处理从源加载内容,无论是本地还是远程,因此需要我们自己编写代码实现,但也因此相对灵活,在处理过程中需要配合相关插件来实现内容转换处理,如:gray-matter等。
[contentlayer](#名称 差异描述 @next/mdx Next.js官方提供的markdown 和 MDX解决方案,它从本地文件获取数据,允许您直接在/pages或/app目录中创建带有扩展名.mdx的页面。对于简单内容页面来说相对实用。 next-mdx-remote 不处理从源加载内容,无论是本地还是远程,因此需要我们自己编写代码实现,但也因此相对灵活,在处理过程中需要配合相关插件来实现内容转换处理,如:gray-matter等。 contentlayer 具有重量轻,易于使用、 出色的开发体验以及快速的构建能力和高性能页面的优点的。它从源文件加载内容,并自动生成 TypeScript 类型定义,以确保正在处理的内容符合您期望的形状。 "#contentlayer") 具有重量轻,易于使用、 出色的开发体验以及快速的构建能力和高性能页面的优点的。它从源文件加载内容,并自动生成 TypeScript 类型定义,以确保正在处理的内容符合您期望的形状。

好了,让我们开始真正的MDX应用搭建之旅吧!

准备

确保已经使用create-next-app创建了一个基础应用(该基础应用将用于搭建MDX博客应用三种方法的基本结构),若没有,请先运行以下代码进行创建:

bash 复制代码
pnpm dlx create-next-app@latest

根据命令行提示,选择您喜欢的配置,在本示例流程中我们选择如下:

plaintext 复制代码
What is your project named? next-mdx-app
Would you like to use TypeScript? No / Yes√
Would you like to use ESLint? No / Yes√
Would you like to use Tailwind CSS? No / Yes√
Would you like to use `src/` directory? No√ / Yes
Would you like to use App Router? (recommended) No / Yes√
Would you like to customize the default import alias (@/*)? No / Yes√
What import alias would you like configured? @/*

选择Tailwind CSS是为了方便后续页面排版,当然也可以根据您的喜好不选择。

快捷浏览:[Next mdx](#Next mdx "#next-mdx")、[Next mdx remote](#Next mdx remote "#next-mdx-remote")、Contentlayer

Next mdx

安装渲染MDX所需的软件包

bash 复制代码
pnpm add @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

在您应用的根目录下(/app/src目录的父级目录),创建一个mdx-components.tsx文件:

注意:没有这个文件在App Router模式下将无法正常运行。如果使用Pages Router则可忽略这一步。

ts 复制代码
import type { MDXComponents } from 'mdx/types'
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {...components }
}

更新项目根目录下的next.config.js文件,将其配置为使用MDX

ts 复制代码
const withMDX = require('@next/mdx')()
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions`` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}
 
module.exports = withMDX(nextConfig)

然后,在您项目的/app目录下创建一个MDX页面:

plaintext 复制代码
your-project
├── app
│   └── my-mdx-page
│       └── page.mdx
└── package.json

现在,在其它地方创建一个react组件my-components.tsx,然后就可以直接在my-mdx-page/page.mdx文件中直接使用markdown和导入创建的react组件

markdown 复制代码
import { MyComponent } from 'my-components'
 
# Welcome to my MDX page!
 
This is some **bold** and _italics_ text.
 
This is a list in markdown:
 
- One
- Two
- Three
 
Checkout my React component:
 
<MyComponent />

导航到/my-mdx-page路由,将看到您所创建的MDX页面了。

以上即为@next/mdx官方实现方式,非常简单。但相对也有一定局限情,因为它只处理本地的MDX页面,需要以Next.js路由的方式来管理MDX文章内容。

Next mdx remote

next-mdx-remote允许您在其它地方动态加载markdownMDX内容文件,并在客户端上正确渲染的轻型实用程序。

添加文章内容

/posts目录中创建几个markdown文件,并向这些文件添加一些内容。如下是一个/posts/post-01.md示例:

markdown 复制代码
---
title: My First Post
date: 2022-02-22T22:22:22+0800
---

This is my first post ...

在此目录中将有三个帖子示例:

plaintext 复制代码
posts/
├── post-01.md
├── post-02.md
└── post-03.md

解析内容

安装MDX解析所需的软件包

bash 复制代码
pnpm add next-mdx-remote gray-matter

创建 posts 资源获取/lib/posts.ts文件: 在这里我们需要使用gray-matter插件来解析 markdown 内容。

ts 复制代码
import fs from "fs";
import { join } from "path";

import matter from "gray-matter";
const postsDir = join(process.cwd(), "posts");

type MetaData = {
  title: string;
  date: Date;
  category: string;
  tags?: string[];
  description?: string;
  draft?: boolean;
};

// 根据文件名读取 markdown 文档内容
export function getPostBySlug(slug: string) {
  const realSlug = slug.replace(/\.md$/, "");

  const fullPath = join(postsDir, `${realSlug}.md`);

  const fileContents = fs.readFileSync(fullPath, "utf8");

  // 解析 markdown 元数据
  const { data, content, excerpt } = matter(fileContents, {
    excerpt: true,
  });

  // 配置文章元数据
  const meta = { ...data } as MetaData;

  return { slug: realSlug, meta, content, excerpt };
}

// 获取 /posts文件夹下所用markdown文档
export function getAllPosts() {
  const slugs = fs.readdirSync(postsDir);

  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    // 排除草稿文件
    .filter((c) => !/\.draft$/.test(c.slug));
    // .filter((c) => !c.meta.draft);
  return posts.sort((a, b) => +b.meta.date - +a.meta.date);
}

添加网站代码

创建/app/posts/page.tsx用于展示所有Post文章列表。

tsx 复制代码
import Link from "next/link";

import { getAllPosts } from "@/lib/posts";

export default async function Posts() {
  const posts = await getAllPosts();

  return (
    <div className="prose grid gap-9 m-auto">
      {posts?.map((post: any) => (
        <Link
          href={`/posts/${post.slug}`}
          className="group font-normal overflow-hidden cursor-pointer no-underline transition fade-in-up "
          key={post.slug}
        >
          <div className="text-xl text-gray-600 group-hover:text-brand truncate ease-in duration-300">
            {post.meta?.title}
          </div>
          <time className="text-gray-400 text-sm leading-none flex items-center">
            {post.meta?.date?.toString()}
          </time>
        </Link>
      ))}
    </div>
  );
}

运行Next.js开发服务,并访问localhost:3000/posts查看文章列表。

bash 复制代码
pnpm dev

添加Post布局

创建文章呈现页面/app/posts/[slug]/page.tsx

tsx 复制代码
import { MDXRemote } from "next-mdx-remote/rsc";

import { getPostBySlug, getAllPosts } from "@/lib/posts";

type Props = {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

async function getPost(params: Props["params"]) {
  const post = getPostBySlug(params.slug);
  return { post };
}

export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = getAllPosts();

  return posts.map((post) => ({ slug: post.slug }));
}

export default async function Post({ params }: Props) {
  const { post } = await getPost(params);

  return (
    <>
      <h1 className="text-2xl">{post.meta.title}</h1>
      <time className="text-gray-600">{post.meta?.date.toString()}</time>
      <MDXRemote source={post.content} components={{}} options={{}} />
    </>
  );
}

引用组件

创建一个MDX使用的组件/app/posts/[slug]/mdx/Button.tsx

tsx 复制代码
"use client";

import { useState } from "react";

export default function Button({ text }: { text: string }) {
  const [toggle, setToggle] = useState(false);

  return (
    <button onClick={() => setToggle(!toggle)}>
      {toggle ? text : "Click Me"}
    </button>
  );
}

注意:在App Router中,需对客户端渲染组件添加use client;

在文章呈现页面/app/posts/[slug]/page.tsx中引入创建的组件

diff 复制代码
import { MDXRemote, MDXRemoteProps } from "next-mdx-remote/rsc";

import { getPostBySlug, getAllPosts } from "@/lib/posts";

+ import Button from "./mdx/Button";

...

export default async ({ params }: Props) => {
  const { post } = await getPost(params);

  return (
    <>
      ...
+     <MDXRemote source={post.content} components={{Button}} options={{}} />
    </>
  );
};

然后,在/posts文件夹中的文章中使用定义的Button组件

diff 复制代码
---
title: My First Post
date: 2022-02-22T22:22:22+0800
---

This is my first post ...

+ <Button text="Button" />

现在,导航到/posts/post-01,将看到一个带有一个按钮的可交互的Markdown文档。🎉🎉🎉🎉🎉

Contentlayer

Contentlayer 是一个内容 SDK,可验证您的内容并将其转换为类型安全的 JSON 数据,您可以轻松地import将其添加到应用程序的页面中。

⚠️ Contentlayer 目前处于测试阶段。在即将发布的 1.0 版本之前,可能仍会发生重大更改。

开始

安装 Contentlayer 和 Next.js 插件

bash 复制代码
pnpm add contentlayer next-contentlayer

使用withContentlayer方法包裹Next.js配置,以便将ContentLayer钩子挂接到next devnext build过程中。

js 复制代码
// next.config.js
const { withContentlayer } = require('next-contentlayer')

/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true }

module.exports = withContentlayer(nextConfig)

然后,添加下面行中的代码到tsconfig.jsonjsconfig.json文件中。

json 复制代码
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    //  ^^^^^^^^^^^
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
      // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".contentlayer/generated"
    // ^^^^^^^^^^^^^^^^^^^^^^
  ]
}

这些配置将使告诉Next.js构建过程和你的编辑器在哪里寻找生成的文件,并让它们在你的代码更容易导入。

忽略构建输出

.contentlayer目录添加到你的.gitignore文件中,以确保你的应用程序的每个构建都有最新生成的数据,并且你不会遇到Git问题。

plaintext 复制代码
# .gitignore

# ...

# contentlayer
.contentlayer

添加配置

在项目的根部创建文件contentlayer.config.ts,然后添加以下内容。

ts 复制代码
// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'

// 文档类型
export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.md`,
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
  },
  computedFields: {
    url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
  },
}))

export default makeSource({ contentDirPath: 'posts', documentTypes: [Post] })

该配置指定了一个名为Post的文档类型。这些文档是位于项目中的posts目录中的Markdown文件。

从这些文件生成的任何数据对象都将包含上面指定的字段,以及包含文件的原始内容和HTML内容的正文字段。url字段是一个特殊的计算字段,它会根据源文件中的元属性自动添加到所有发布文档中。

添加文章内容

/posts目录中创建几个markdown文件,并向这些文件添加一些内容。如下是一个/posts/post-01.md示例:

markdown 复制代码
---
title: My First Post
date: 2022-02-22T22:22:22+0800
---

This is my first post ...

在此目录中将有三个帖子示例:

plaintext 复制代码
posts/
├── post-01.md
├── post-02.md
└── post-03.md

添加网站代码

创建/app/posts/page.tsx用于展示所有Post文章列表。请注意,在尝试从contentlayer/regenerated导入时会出现错误,这是正常的,稍后将通过运行开发服务器来修复它。

tsx 复制代码
// app/page.tsx
import Link from "next/link";
import { compareDesc, format, parseISO } from "date-fns";
import { allPosts, Post } from "contentlayer/generated";

function PostCard(post: Post) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link
          href={post.url}
          className="text-blue-700 hover:text-blue-900 dark:text-blue-400"
        >
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {format(parseISO(post.date), "LLLL d, yyyy")}
      </time>
      <div
        className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0"
        dangerouslySetInnerHTML={{ __html: post.body.html }}
      />
    </div>
  );
}

export default function Home() {
  const posts = allPosts.sort((a, b) =>
    compareDesc(new Date(a.date), new Date(b.date))
  );

  return (
    <div className="mx-auto max-w-xl py-8">
      {posts.map((post, idx) => (<PostCard key={idx} {...post} />))}
    </div>
  );
}

运行Next.js开发服务,并访问localhost:3000/posts查看文章列表。

bash 复制代码
pnpm dev

添加Post布局

现在创建app/posts/[slug]/page.tsx页面,并添加以下代码

tsx 复制代码
// app/posts/[slug]/page.tsx
import { format, parseISO } from 'date-fns'
import { allPosts } from 'contentlayer/generated'

export const generateStaticParams = async () => allPosts.map((post) => ({ slug: post._raw.flattenedPath }))

export const generateMetadata = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`)
  return { title: post.title }
}

const PostLayout = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`)

  return (
    <article className="mx-auto max-w-xl py-8">
      <div className="mb-8 text-center">
        <time dateTime={post.date} className="mb-1 text-xs text-gray-600">
          {format(parseISO(post.date), 'LLLL d, yyyy')}
        </time>
        <h1 className="text-3xl font-bold">{post.title}</h1>
      </div>
      <div className="[&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />
    </article>
  )
}

export default PostLayout;

现在,点击文章列表上的链接,将进入一文章阅读页面。

开启MDX

Contentlayer中使用MDX只需在配置文件contentlayer.config.ts中添加如下代码即可

diff 复制代码
...
export const Post = defineDocumentType(() => ({
...
+ contentType: 'mdx',
...
}));
...

创建一个MDX使用的组件/app/posts/[slug]/mdx/Button.tsx

tsx 复制代码
"use client";

import { useState } from "react";

export default function Button({ text }: { text: string }) {
  const [toggle, setToggle] = useState(false);

  return (
    <button onClick={() => setToggle(!toggle)}>
      {toggle ? text : "Click Me"}
    </button>
  );
}

注意:在App Router中,需对客户端渲染组件添加use client;

然后,在app/posts/[slug]/page.tsx文件中作如下调整

diff 复制代码
...
+ import { useMDXComponent } from "next-contentlayer/hooks";
+ import Button from "./mdx/Button";
...
const PostLayout = ({ params }: { params: { slug: string } }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.slug);
  if (!post) throw new Error(`Post not found for slug: ${params.slug}`);

+  const MDXContent = useMDXComponent(post.body.code);

  return (
    <article className="mx-auto max-w-xl py-8">
 ...
-     <div className="[&>*]:mb-3 [&>*:last-child]:mb-0" dangerouslySetInnerHTML={{ __html: post.body.html }} />

+     <div className="[&>*]:mb-3 [&>*:last-child]:mb-0">
+       <MDXContent components={{ Button }} />
+     </div>
    </article>
  );
};

...

最后删除/app/posts/page.tsx文件中如下代码

diff 复制代码
- <div
-   className="text-sm [&>*]:mb-3 [&>*:last-child]:mb-0"
-   dangerouslySetInnerHTML={{ __html: post.body.html }}
-  />

此时,带交互功能的文件文章配置就完成啦。

扩展

在解决MDX内容呈现后,我们可能还需要对MDX文档内容的frontmatter数据提取、表格、目录、阅读时间、字数统计以及代码内容美化等操作。此时,我们需要用到remarkrehype生态中的一些插件,使用方式也很简单。参见如下配置:

Next mdx

布局

@next/mdx中处理MDX页面布局与常规Next.js页面布局一样,在当前页面目录下(或其父目录下)创建一个layout.tsx文件,然后编写布局代码即可。

元数据

@next/mdx中处理页面元数据时,我们需要自己创建一个相对应的元数据处理组件例如:

tsx 复制代码
type FrontmatterProps = {
  date: string;
  author: string;
  // 其它元数据,如分类、标签、来源、阅读时长等
};

export default function Frontmatter({ date, author }: FrontmatterProps) {
  return (
    <div className="frontmatter">
      date: <time>{date}</time>
      author: {author}
    </div>
  );
}

然后,在page.mdx页面中合适的位置放入该组件,并配置上元数据即可。例如:

diff 复制代码
import MyComponent from './my-components'
+ import Frontmatter from './frontmatter'

# Welcome to my MDX page!

+ <Frontmatter date="2023-12-12 12:12:12" author="Qhan W"/>
 
This is some **bold** and _italics_ text.
 
This is a list in markdown:
...

官方元数据处理:frontmatter

MDX插件配置

@next/mdxnext-mdx-remotecontentlayer中都可以通过remark插件rehype来转换 MDX 内容。例如,使用remark-gfm来实现 GitHub Flavored Markdown 来支持。

@next/mdx

注意:由于remark和rehype生态系统仅是 ESM,因此,需要将配置文件next.config.js改为next.config.mjs。插件配置如下:

js 复制代码
// next.config.mjs
import remarkGfm from 'remark-gfm'
import createMDX from '@next/mdx'
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure `pageExtensions`` to include MDX files
  pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
  // Optionally, add any other Next.js config below
}
 
const withMDX = createMDX({
  // Add markdown plugins here, as desired
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [],
  },
})
 
// Merge MDX config with Next.js config
export default withMDX(nextConfig)

next-mdx-remote

ts 复制代码
import { MDXRemote, MDXRemoteProps } from "next-mdx-remote/rsc";

import remarkToc from "remark-toc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";

const options: MDXRemoteProps["options"] = {
  mdxOptions: {
    remarkPlugins: [[remarkToc, { maxDepth: 4 }], remarkGfm],
    rehypePlugins: [rehypeSlug],
  },
};

export default function MDXContent(props: Pick<MDXRemoteProps, "source">) {
  return (
    <article className="fade-in-up-content prose prose-gray">
      <MDXRemote source={props.source} options={options} />
    </article>
  );
}

contentlayer

ts 复制代码
// contentlayer.config.ts
import { makeSource } from '@contentlayer/source-files'
import highlight from 'rehype-highlight'
import remarkGfm from 'remark-gfm'

export default makeSource({
  // ...
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [highlight],
  },
})

代码高亮

在作为技术开发为主的博客中,常常会用到代码示例,这里推荐使用Anthony Furehype-shikiji插件,按插件配置配置即可。其它优秀的代码高亮插件如下:

阅读时间

通过reading-time可以为我们的文章添加阅读时间、文章字数元数据。

在配置文件contentlayer.config.ts中添加以下代码可为contentlayer添加文章阅读时长

diff 复制代码
...
+ import readingTime from "reading-time";

// 文档类型
export const Post = defineDocumentType(() => ({
...
  computedFields: {
    ...
+   readingTime: { type: "json", resolve: (doc) => readingTime(doc.body.raw) },
  },
}));
...

同样在/lib/posts.ts文件中作如下修改也可为next-mdx-remote添加文章阅读时长

diff 复制代码
...
+ import readingTime from "reading-time";

const postsDir = join(process.cwd(), "posts");

+ type ReadingTime = {
+  text: string;
+  minutes: number;
+  time: number;
+  words: number;
+ };

type MetaData = {
...
+  readingTime?: ReadingTime;
};

export function getPostBySlug(slug: string) {
...
  const { data, content, excerpt } = matter(fileContents, {
    excerpt: true,
  });

+ const readTime = readingTime(content);
+ const meta = { ...data, readingTime: readTime } as MetaData;
  ...
}

...

Table of Content

在本文介绍的三个方法中,我们可以通remark-toc插件得到文章的目录。但目录的位置在文章中配置的地方显示,这可能不符合我们预期,在此情况下,可通过样式将目录放置合适合的位置,如:

该样式将目录放在文章右侧,并在小屏幕中隐藏。

css 复制代码
#toc {
  display: none;
}

#toc + ul {
  display: none;
  position: fixed;
  right: 16px;
  top: 115px;
  margin: 0;
  padding: 0;
  max-width: 160px;
  max-height: 480px;
  overflow: auto;

  &::before {
    display: table;
    content: "Table of Contents";
    color: rgba(42, 46, 54, 0.45);
    font-weight: bold;
  }
}

#toc + ul,
#toc + ul ul {
  list-style-type: none;
  font-size: 14px;
  margin: 0;

  > li > a {
    text-decoration: none;
    color: rgb(55, 65, 81);
    font-weight: normal;
  }
}

@media (min-width: 1024px) {
  #toc + ul {
    display: block !important;
  }
}

.prose .shiki {
  font-family: DM Mono, Input Mono, Fira Code, monospace;
  font-size: 0.92em;
  line-height: 1.4;
  // margin: 0.5em 0;
}

// TODO: shikiji 未对纯文本样式做适配
.prose .shiki.nord[lang=plaintext] :where(code) {
  color: #d8dee9ff;
}

异常处理

时间格式化

因为我们使用Next.js来搭建博客,并采用服务端渲染方式,因此,在文章内容的发布时间与编辑时间上,需要带上时区信息。否则,在渲染时会出现服务器与客户端时区不一致,导致时间错误问题。对于时间的格式化处理,此处统一采用客户端渲染 方式。具体请查看SSR Timezone

插件异常

主要为remark-gfm插件错误。撰写本示例时,正值remarkjs相关插件升级中,因些,在使用next-mdx-remotecontentlayer时出现渲染错误,此时,我们只需回退remark-gfm到上一个大版本即可,即: v3.x。

VS Code TS错误

表现为@next/mdx下,page.mdx出现ts检查错误,重启编辑器即可。

相关链接

相关推荐
m0_7482561419 分钟前
前端 MYTED单篇TED词汇学习功能优化
前端·学习
小白学前端6661 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
web130933203982 小时前
前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案
前端
outstanding木槿2 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08213 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
隐形喷火龙3 小时前
element ui--下拉根据拼音首字母过滤
前端·vue.js·ui
m0_748241123 小时前
Selenium之Web元素定位
前端·selenium·测试工具
等一场春雨3 小时前
springboot 3 websocket react 系统提示,选手实时数据更新监控
spring boot·websocket·react.js
风无雨3 小时前
react杂乱笔记(一)
前端·笔记·react.js
前端小魔女3 小时前
2024-我赚到自媒体第一桶金
前端·rust