前言
要用到的 packages 有:
- next Next.js 框架
- next-mdx-remote 處理和加載 mdx 內容
- gray-matter 分析 markdown 中的 front matter
本次教學的 repository:
https://github.com/tszhong0411/nextjs-mdx-blog
樣品

如何建立 blog
首先,我們用以下指令建立 Next.js 項目:
Terminal
yarn create next-app nextjs-mdx-blog
Terminal
yarn create next-app nextjs-mdx-blog
接著,再建立以下檔案:
components\Layout.js
- 把 components 都包起來,作為 container 的用途 (可選,只是樣式而已)date\blog\*.mdx
- 部落格文章lib\formatDate.js
- 把日期格式化為YYYY年MM月DD日
[slug].js
- 文章頁面,使用 dynamic routes
components
Layout.js
data
blog
markdown.mdx
nextjs.mdx
react.mdx
lib
formatDate.js
mdx.js
pages
blog
[slug].js
如何處理 Markdown 檔案
先 const root
const root
為根目錄,process.cwd()
process.cwd()
方法返回 Node.js 進程的當前工作目錄。
lib/mdx.js
const root = process.cwd()
lib/mdx.js
const root = process.cwd()
再寫一個變量 POSTS_PATH
為 文章檔案存放的路徑。
lib/mdx.js
import path from 'path'
const POSTS_PATH = path.join(root, 'data', 'blog')
// 輸出: A:\nextjs-mdx-blog\data\blog
lib/mdx.js
import path from 'path'
const POSTS_PATH = path.join(root, 'data', 'blog')
// 輸出: A:\nextjs-mdx-blog\data\blog
再用 fs 閱讀讀取該目錄的內容,也即是 data\blog
下的所有檔案名稱。
lib/mdx.js
import fs from 'fs'
export const allSlugs = fs.readdirSync(POSTS_PATH)
// 輸出: ['markdown.mdx', 'nextjs.mdx', 'react.mdx']
lib/mdx.js
import fs from 'fs'
export const allSlugs = fs.readdirSync(POSTS_PATH)
// 輸出: ['markdown.mdx', 'nextjs.mdx', 'react.mdx']
然後寫一個可以把副檔名移除的功能,等一下會用到。
lib/mdx.js
export const formatSlug = (slug) => slug.replace(/\.mdx$/, '')
/**
* 例如 formatSlug('markdown.mdx')
* 輸出: 'markdown'
*/
lib/mdx.js
export const formatSlug = (slug) => slug.replace(/\.mdx$/, '')
/**
* 例如 formatSlug('markdown.mdx')
* 輸出: 'markdown'
*/
接著是用 slug 取得文章內容。
lib/mdx.js
export const getPostBySlug = async (slug) => {
const postFilePath = path.join(POSTS_PATH, `${slug}.mdx`)
// 輸出: A:\nextjs-mdx-blog\data\blog\slug.mdx
const source = fs.readFileSync(postFilePath)
// 返回檔案內容
const { content, data } = matter(source)
/*
* 例如:
* ---
* title: Hello
* slug: home
* ---
* <h1>Hello world!</h1>
*
* 返回:
* {
* content: '<h1>Hello world!</h1>',
* data: {
* title: 'Hello',
* slug: 'home'
* }
* }
*/
const mdxSource = await serialize(content)
// 把 content 丟到 serialize (next-mdx-remote) 處理
const frontMatter = {
...data,
slug,
}
// 把 slug 也放到 front matter 中,之後會用到
return {
source: mdxSource,
frontMatter,
}
}
lib/mdx.js
export const getPostBySlug = async (slug) => {
const postFilePath = path.join(POSTS_PATH, `${slug}.mdx`)
// 輸出: A:\nextjs-mdx-blog\data\blog\slug.mdx
const source = fs.readFileSync(postFilePath)
// 返回檔案內容
const { content, data } = matter(source)
/*
* 例如:
* ---
* title: Hello
* slug: home
* ---
* <h1>Hello world!</h1>
*
* 返回:
* {
* content: '<h1>Hello world!</h1>',
* data: {
* title: 'Hello',
* slug: 'home'
* }
* }
*/
const mdxSource = await serialize(content)
// 把 content 丟到 serialize (next-mdx-remote) 處理
const frontMatter = {
...data,
slug,
}
// 把 slug 也放到 front matter 中,之後會用到
return {
source: mdxSource,
frontMatter,
}
}
然後是取得所有文章,在首頁中顯示。
lib/mdx.js
export const getAllPosts = () => {
const frontMatter = []
allSlugs.forEach((slug) => {
const source = fs.readFileSync(path.join(POSTS_PATH, slug), 'utf-8')
const { data } = matter(source)
frontMatter.push({
...data,
slug: formatSlug(slug),
date: new Date(data.date).toISOString(),
})
})
return frontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}
// 根據日期由大至小排序
const dateSortDesc = (a, b) => {
if (a > b) return -1
if (a < b) return 1
return 0
}
lib/mdx.js
export const getAllPosts = () => {
const frontMatter = []
allSlugs.forEach((slug) => {
const source = fs.readFileSync(path.join(POSTS_PATH, slug), 'utf-8')
const { data } = matter(source)
frontMatter.push({
...data,
slug: formatSlug(slug),
date: new Date(data.date).toISOString(),
})
})
return frontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}
// 根據日期由大至小排序
const dateSortDesc = (a, b) => {
if (a > b) return -1
if (a < b) return 1
return 0
}
格式化日期
lib/formatDate.js
export const formatDate = (date) =>
new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
/*
* formatDate('2022-08-21T00:00:00Z')
* 輸出: '2022年8月21日'
*/
lib/formatDate.js
export const formatDate = (date) =>
new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
/*
* formatDate('2022-08-21T00:00:00Z')
* 輸出: '2022年8月21日'
*/
首頁
pages/index.js
import { formatDate } from '../lib/formatDate'
import { getAllPosts } from '../lib/mdx'
import Link from 'next/link'
export default function Home({ posts }) {
return (
<>
<h1 className='mb-8 text-6xl font-bold'>Blog</h1>
<hr className='my-8' />
<ul className='flex flex-col gap-3'>
{posts.map(({ slug, title, summary, date }) => (
<li key={slug}>
<Link href={`/blog/${slug}`}>
<a className='block rounded-lg border border-solid border-gray-300 p-6 shadow-md'>
<div className='flex justify-between'>
<h2>{title}</h2>
<time dateTime={date}>{formatDate(date)}</time>
</div>
<p className='mt-4'>{summary}</p>
</a>
</Link>
</li>
))}
</ul>
</>
)
}
// 使用 getStaticProps 取得所有文章
export const getStaticProps = async () => {
const posts = getAllPosts()
return {
props: {
posts,
},
}
}
pages/index.js
import { formatDate } from '../lib/formatDate'
import { getAllPosts } from '../lib/mdx'
import Link from 'next/link'
export default function Home({ posts }) {
return (
<>
<h1 className='mb-8 text-6xl font-bold'>Blog</h1>
<hr className='my-8' />
<ul className='flex flex-col gap-3'>
{posts.map(({ slug, title, summary, date }) => (
<li key={slug}>
<Link href={`/blog/${slug}`}>
<a className='block rounded-lg border border-solid border-gray-300 p-6 shadow-md'>
<div className='flex justify-between'>
<h2>{title}</h2>
<time dateTime={date}>{formatDate(date)}</time>
</div>
<p className='mt-4'>{summary}</p>
</a>
</Link>
</li>
))}
</ul>
</>
)
}
// 使用 getStaticProps 取得所有文章
export const getStaticProps = async () => {
const posts = getAllPosts()
return {
props: {
posts,
},
}
}
文章頁面
pages/[slug].js
import { formatDate } from '../../lib/formatDate'
import { allSlugs, formatSlug, getPostBySlug } from '../../lib/mdx'
import { MDXRemote } from 'next-mdx-remote'
export default function Blog({ post }) {
const { title, date } = post.frontMatter
return (
<div>
<h1 className='mb-2 text-6xl font-bold'>{title}</h1>
<time dateTime={date} className='text-lg font-medium'>
{formatDate(date)}
</time>
<hr className='my-8' />
<article className='prose'>
<MDXRemote {...post.source} />
</article>
</div>
)
}
export const getStaticProps = async ({ params }) => {
const post = await getPostBySlug(params.slug)
return {
props: {
post,
},
}
}
export const getStaticPaths = async () => {
const paths = allSlugs.map((slug) => ({
params: {
slug: formatSlug(slug),
},
}))
/*
* paths 輸出:
* [
* { params: { slug: 'markdown' } },
* { params: { slug: 'nextjs' } },
* { params: { slug: 'react' } }
* ]
*/
return {
paths,
fallback: false,
}
}
pages/[slug].js
import { formatDate } from '../../lib/formatDate'
import { allSlugs, formatSlug, getPostBySlug } from '../../lib/mdx'
import { MDXRemote } from 'next-mdx-remote'
export default function Blog({ post }) {
const { title, date } = post.frontMatter
return (
<div>
<h1 className='mb-2 text-6xl font-bold'>{title}</h1>
<time dateTime={date} className='text-lg font-medium'>
{formatDate(date)}
</time>
<hr className='my-8' />
<article className='prose'>
<MDXRemote {...post.source} />
</article>
</div>
)
}
export const getStaticProps = async ({ params }) => {
const post = await getPostBySlug(params.slug)
return {
props: {
post,
},
}
}
export const getStaticPaths = async () => {
const paths = allSlugs.map((slug) => ({
params: {
slug: formatSlug(slug),
},
}))
/*
* paths 輸出:
* [
* { params: { slug: 'markdown' } },
* { params: { slug: 'nextjs' } },
* { params: { slug: 'react' } }
* ]
*/
return {
paths,
fallback: false,
}
}
這樣簡易的 Blog 就大功告成了。