visit
my-blog
├── public
├── data
│ └── blogs.json
├── components
│ └── Blog.tsx
└── pages
├── blogs
│ ├── blog-one.mdx
│ ├── blog-two.mdx
│ └── blog-three.mdx
└── index.tsx
Take pages/blogs/blog-one.mdx
for example, the content looked like this:
pages/blogs/blog-one.mdx
import Blog from '../../components/Blog'
export const meta = {
title: 'Blog One🚀',
publishedAt: 'February 4, 2022'
description: "Learn how to build a Next.js blog with MDX and Contentlayer!",
cover: '/optimized/articles/blog-one/hero.webp',
}
export default ({ children }) => (
<Blog
title={meta.title}
description={meta.description}
cover={meta.cover}
publishedAt={meta.publishedAt}
>
{children}
</Blog>
)
Hey There👋
Welcome to Blog one✨ Let's learn together!
blog-one.mdx
named-exported a meta data. It was picked up by the the default component that took care of the layout and rendered the meta data.
The <Blog />
component looked like this:
components/Blog.tsx
import { BlogProps } from './types'
export default function Blog(props: BlogProps) {
return (
<article>
<h1>{props.title}</h1>
<h2>{props.description}</h2>
<p>
{props.publishedAt}
</p>
<img alt={props.title} src={props.cover} width="100%" loading="lazy" />
{props.children}
</article>
)
}
Because the meta
data in each MDX file was trapped in the page, I duplicated all the meta data and aggregated them in data/blogs.json
. I used it to maintain the list of articles on my website, the RSS feed, and the for SEO.
It would be much better if I could treat the MDX files as data, and generate pages based on the data.
This way, I could use the MDX files as data points and page content at the same time. Publishing a new article ideally could be much more frictionless.
---
title: 'Blog One🚀'
publishedAt: 'February 4, 2022'
description: 'Learn how to build a Next.js blog with MDX and Contentlayer!'
cover: '/optimized/articles/blog-one/hero.webp'
---
Hey There👋
Welcome to Blog One✨ Let's learn together!
You can see the meta data in YAML syntax is inside the ---
block, and the body of the content follows in MDX syntax. Compared to the where MDX files were treated as pages, the new MDX file contains only meta data and content.
The next thing we need to do is to generate the blog page that renders the meta data and the content with the layout from <Blog />
component.
Now that we updated the MDX files to contain only data and content, Let’s move them into the data
directory.
my-blog
├── public
├── components
│ └── Blog.tsx
├── pages
│ ├── blogs
│ │ └── [slug].tsx
│ └── index.tsx
└── data
└──blogs
├── blog-one.mdx
├── blog-two.mdx
└── blog-three.mdx
Notice that we replaced the MDX files in pages/blogs
directory with a [slug].tsx
. We'll use this page to statically generate the blog pages .
yarn add contentlayer next-contentlayer
Contentlayer reads the configuration from contentlayer.config.ts
. Let's create one.
touch contentlayer.config.ts
Inside the contentlayer.config.ts
, we need to add instructions to tell Contentlayer how to parse:
name
: namespacefilePathPattern
: input filesbodyType
: content body type for parsingfields
: meta data fieldscomputedFields
: derived meta data fields
contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
import readingTime from 'reading-time'
export const Blog = defineDocumentType(() => ({
name: 'Blog',
filePathPattern: 'blogs/*.mdx',
bodyType: 'mdx',
fields: {
title: { type: 'string', required: true },
publishedAt: { type: 'string', required: true },
description: { type: 'string', required: true },
cover: { type: 'string', required: true },
},
computedFields: {
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
slug: {
type: 'string',
resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx/, ''),
},
},
}))
export default makeSource({
contentDirPath: 'data',
documentTypes: [Blog],
mdx: {
remarkPlugins: [],
rehypePlugins: [],
},
})
In the computedFields
, we can compute data like readingTime
from the content body🤩. I'm using reading-time
for calculating the reading time based on word count. The slug
field is for generating the dynamic route later in the [slug].tsx
page.
Under the hood, Contentlayer uses mdx-bundler
to parse MDX and YAML frontmatter and extract the content and data. If you're interested in the magic behind it, you can read more about gray-matter
and remark-mdx-frontmatter
. These are the libraries mdx-bundler
uses internally.
At the end of the configuration, makeSource
will then look for files that match blogs/*.mdx
pattern under data
directory and generate the blog data in .contentlayer
directory at your project root.
Lastly, wrap your Next.js configuration with next-contentlayer
to integrate with Next.js's live-reload and build process.
next.config.js
const { withContentlayer } = require('next-contentlayer')
module.exports = withContentlayer()({
// ... your Next.js config
})
All we need to do is to use allBlogs
from .contentlayer/data
to build the dynamic routes with getStaticPaths
and use getStaticProps
to pass the blog data to the [slug].tsx
page.
pages/blogs/[slug].tsx
import { useMDXComponent } from 'next-contentlayer/hooks'
import { allBlogs } from '.contentlayer/data'
import type { Blog } from '.contentlayer/types'
import BlogLayout from '../../../components/Blog'
type BlogProps = {
blog: Blog
}
export default function Blog({ blog }: BlogProps) {
const Component = useMDXComponent(post.body.code)
return (
<BlogLayout {...blog}>
<Component />
</BlogLayout>
)
}
export async function getStaticPaths() {
return {
paths: allBlogs.map((blog) => ({ params: { slug: blog.slug } })),
fallback: false,
}
}
export async function getStaticProps({ params }) {
const blog = allBlogs.find((blog) => blog.slug === params.slug)
return { props: { blog } }
}
After the project is built, you’ll see the blogs available at /blogs/blog-one
, /blogs/blog-two
, and /blogs/blog-three
✨
There are a lot more we can do with MDX by leveraging remark and rehype plugins in the contentlayer.config.ts
.
MDX ----> remark AST ------> rehype AST --------> HTML
parse convert stringify
remark-gfm
to support .rehype-slug
and rehype-autolink-headings
to render heading links.rehype-prism-plus
to render syntax highlighting in code blocks.rehype-code-titles
to render code block titles.rehype-accessible-emojis
to provide accessibility to emojis.
+ import remarkGfm from 'remark-gfm'
+ import rehypeSlug from 'rehype-slug'
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings'
+ import rehypeCodeTitles from 'rehype-code-titles'
+ import rehypePrism from 'rehype-prism-plus'
+ import { rehypeAccessibleEmojis } from 'rehype-accessible-emojis'
// ...
export default makeSource({
mdx: {
- remarkPlugins: [],
+ remarkPlugins: [remarkGfm],
- rehypePlugins: [],
+ rehypePlugins: [
+ rehypeSlug,
+ rehypeCodeTitles,
+ rehypePrism,
+ rehypeAutolinkHeadings,
+ rehypeAccessibleEmojis,
],
},
})
I can now write a script to generate an RSS feed base on the allBlogs
data!
scripts/rss.mjs
import { writeFileSync } from 'fs'
import RSS from 'rss'
import { allBlogs } from '.contentlayer/data'
const feed = new RSS({
title: "My Blogs",
feed_url: 'localhost:3000/rss.xml',
site_url: 'localhost:3000',
})
allBlogs.map((blog) => ({
title: blog.title,
description: blog.description,
url: `localhost:3000/blogs/${blog.slug}`
date: blog.publishedAt,
})).forEach((item) => {
feed.item(item)
})
writeFileSync('./public/rss.xml', feed.xml({ indent: true }))
It’s easier to write a script for sitemap generation. All we need is the file structure in the data
and page
directories.
scripts/sitemap.mjs
import { writeFileSync } from 'fs'
import { globby } from 'globby'
import prettier from 'prettier'
const pages = await globby([
'pages/*.tsx',
'data/**/*.mdx',
'!pages/_*.tsx',
])
const urlTags = pages
.map((file) =>
file
.replace('pages', '')
.replace('data/content', '')
.replace('.tsx', '')
.replace('.mdx', '')
)
.map((path) => (path === '/index' ? '/' : path))
.map(
(path) => `
<url>
<loc>localhost:3000${path}</loc>
</url>
`
)
.join('')
const sitemap = `
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="//www.sitemaps.org/schemas/sitemap/0.9">
${urlTags}
</urlset>
`
const prettierConfig = await prettier.resolveConfig('./prettierrc')
const formatted = prettier.format(sitemap, {
...prettierConfig,
parser: 'html',
})
writeFileSync('public/sitemap.xml', formatted)
In package.json
, add:
"scripts": {
+ "sitemap": "node scripts/sitemap.mjs",
+ "rss": "node scripts/rss.mjs",
+ "postbuild": "yarn sitemap && yarn rss",
},