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检查错误,重启编辑器即可。

相关链接

相关推荐
恋猫de小郭2 小时前
Flutter 3.35 发布,快来看看有什么更新吧
android·前端·flutter
chinahcp20083 小时前
CSS保持元素宽高比,固定元素宽高比
前端·css·html·css3·html5
gnip4 小时前
浏览器跨标签页通信方案详解
前端·javascript
gnip5 小时前
运行时模块批量导入
前端·javascript
hyy27952276845 小时前
企业级WEB应用服务器TOMCAT
java·前端·tomcat
逆风优雅5 小时前
vue实现模拟 ai 对话功能
前端·javascript·html
若梦plus6 小时前
http基于websocket协议通信分析
前端·网络协议
不羁。。6 小时前
【web站点安全开发】任务3:网页开发的骨架HTML与美容术CSS
前端·css·html
这是个栗子6 小时前
【问题解决】Vue调试工具Vue Devtools插件安装后不显示
前端·javascript·vue.js
姑苏洛言6 小时前
待办事项小程序开发
前端·javascript