Build Faster Next.js Static Sites Using MDX And Contentlayer  by@dawchihliou

Build Faster Next.js Static Sites Using MDX And Contentlayer

image
Daw-Chih Liou HackerNoon profile picture

Daw-Chih Liou

I write for engineers. I write about web technology, coding patterns, and best practices from my learnings.


TL;DR

  • 💨 Contentlayer reduces a lot of friction in publishing the content on my static website.
  • 😍 MDX with remark and rehype plugins is awesomely convenient and powerful.
  • 🍵 Next.js jells very well with Contentlayer and MDX

I fully embraced Static Site Generation (SSG) for my website to optimize the site speed and scaling in the future.

Recently I’ve been researching on reducing the effort to create a new article on my website. There were many touch points in my Next.js project setup in order to:

  • Publish a new article
  • Estimate reading time
  • Update the list of articles
  • Create a new RSS feed
  • Update the sitemap

What Was The Problem?

I analyzed a little more and found out the friction was in my MDX usage. My file structure looked like this:

my-blog
├── public
├── data
│   └── blogs.json
├── components
│   └── Blog.tsx
└── pages
    ├── blogs
    │   ├── blog-one.mdx
    │   ├── blog-two.mdx
    │   └── blog-three.mdx
    └── index.tsx

It is the standard setup recommended by Next.js. I was using @mdx-js/loader and @next/mdx to transform MDX into pages.

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>
  )
}

I was treating MDX files as pages.

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 sitemap 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.

I came across Lee Robinson’s website and found out he was using a alpha library called Contentlayer to solve the problem.

What is Contentlayer

Contentlayer is an library in its early stage that turns content into data. It works roughly like this:

  • It takes in headless CMS or local content in YAML, JSON, MDX, or Markdown as source.

  • It transforms the content into TypeScript types and data files in JSON that includes the original content, meta data, and any derived data we specified.

  • It aggregates all the data in JSON and exports them as ESM.

For my use case, I can use the aggregated data generated by Contentlayer to replace my previous manual process:

  • I use the generated data to build the new page for an article.
  • I use the generated data to render the list of articles.
  • I use the generated data to create a new RSS feed.
  • I use the new file structure to generate a new sitemap.
  • All automatic!

Contentlayer offers easy integration with Next.js. I’ll show you how in the next sections.

Using MDX as Data

Let’s first explore how to use MDX as a data point.

MDX offers YAML frontmatter support with custom parsers. You can express the meta data like this:

---
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 old setup 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.

Integrating Contentlayer in Next.js

Now that we updated the MDX files to contain only data and content, Let’s move them into the data directory.

The new file structure looks like this:

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 dynamic route [slug].tsx. We'll use this page to statically generate the blog pages later.

Configuring Contentlayer

Contentlayer offers seamless integration with Next.js.

To install the dependencies:

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: namespace
  • filePathPattern: input files
  • bodyType: content body type for parsing
  • fields: meta data fields
  • computedFields: 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/*.mdxpattern 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
})

Using Contentlayer data for Static Site Generation

We are ready to use the generated data from Contentlayer and build the static pages🤩

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

Bonus: remark & rehype Plugins

There are a lot more we can do with MDX by leveraging remark and rehype plugins in the contentlayer.config.ts.

  • remark is an awesome plugin ecosystem that transforms markdown.
  • rehype is another powerful plugin ecosystem that transforms HTML.

They are two separate ecosystems but we can convert remark to rehype and generate HTML markup. The transformation looks like this:

MDX ----> remark AST ------> rehype AST --------> HTML
    parse            convert            stringify

Contentlayer takes care of the flow. All we need to do is add the plugins to provide instruction for the transformations. I’m using the following plugins:

  • remark-gfm to support GitHub Flavored Markdown.
  • 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.

contentlayer.config.ts

+ 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,
    ],
  },
})

Contentlayer Applications

There are more things we can do with the data.

Application #1: RSS Feed

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 }))

Application #2: XML Sitemap

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="http://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)

Run both scripts after building the project and automatically generate a new RSS feed and sitemap.

In package.json, add:

"scripts": {
+    "sitemap": "node scripts/sitemap.mjs",
+    "rss": "node scripts/rss.mjs",
+    "postbuild": "yarn sitemap && yarn rss",
  },

Final Thoughts

Building Static sites with Next.js becomes so effortless with MDX and Contentlayer.

MDX combines with remark and rehype ecosystem enriches the possibility of building consistent pages with Markdown. Contentlayer makes data and content in MDX files available to consume in Next.js projects.

If you’re exploring ways to build your own static sites, check out the libraries mentioned earlier. It not only reduces the time to market but also is a lot of fun to build!🦄


This article is originally posted on Daw-Chih’s website.



TL;DR

  • 💨 Contentlayer reduces a lot of friction in publishing the content on my static website.
  • 😍 MDX with remark and rehype plugins is awesomely convenient and powerful.
  • 🍵 Next.js jells very well with Contentlayer and MDX

I fully embraced Static Site Generation (SSG) for my website to optimize the site speed and scaling in the future.

Recently I’ve been researching on reducing the effort to create a new article on my website. There were many touch points in my Next.js project setup in order to:

  • Publish a new article
  • Estimate reading time
  • Update the list of articles
  • Create a new RSS feed
  • Update the sitemap

What Was The Problem?

I analyzed a little more and found out the friction was in my MDX usage. My file structure looked like this:

my-blog
├── public
├── data
│   └── blogs.json
├── components
│   └── Blog.tsx
└── pages
    ├── blogs
    │   ├── blog-one.mdx
    │   ├── blog-two.mdx
    │   └── blog-three.mdx
    └── index.tsx

It is the standard setup recommended by Next.js. I was using @mdx-js/loader and @next/mdx to transform MDX into pages.

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>
  )
}

I was treating MDX files as pages.

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 sitemap 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.

I came across Lee Robinson’s website and found out he was using a alpha library called Contentlayer to solve the problem.

What is Contentlayer

Contentlayer is an library in its early stage that turns content into data. It works roughly like this:

  • It takes in headless CMS or local content in YAML, JSON, MDX, or Markdown as source.

  • It transforms the content into TypeScript types and data files in JSON that includes the original content, meta data, and any derived data we specified.

  • It aggregates all the data in JSON and exports them as ESM.

For my use case, I can use the aggregated data generated by Contentlayer to replace my previous manual process:

  • I use the generated data to build the new page for an article.
  • I use the generated data to render the list of articles.
  • I use the generated data to create a new RSS feed.
  • I use the new file structure to generate a new sitemap.
  • All automatic!

Contentlayer offers easy integration with Next.js. I’ll show you how in the next sections.

Using MDX as Data

Let’s first explore how to use MDX as a data point.

MDX offers YAML frontmatter support with custom parsers. You can express the meta data like this:

---
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 old setup 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.

Integrating Contentlayer in Next.js

Now that we updated the MDX files to contain only data and content, Let’s move them into the data directory.

The new file structure looks like this:

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 dynamic route [slug].tsx. We'll use this page to statically generate the blog pages later.

Configuring Contentlayer

Contentlayer offers seamless integration with Next.js.

To install the dependencies:

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: namespace
  • filePathPattern: input files
  • bodyType: content body type for parsing
  • fields: meta data fields
  • computedFields: 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/*.mdxpattern 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
})

Using Contentlayer data for Static Site Generation

We are ready to use the generated data from Contentlayer and build the static pages🤩

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

Bonus: remark & rehype Plugins

There are a lot more we can do with MDX by leveraging remark and rehype plugins in the contentlayer.config.ts.

  • remark is an awesome plugin ecosystem that transforms markdown.
  • rehype is another powerful plugin ecosystem that transforms HTML.

They are two separate ecosystems but we can convert remark to rehype and generate HTML markup. The transformation looks like this:

MDX ----> remark AST ------> rehype AST --------> HTML
    parse            convert            stringify

Contentlayer takes care of the flow. All we need to do is add the plugins to provide instruction for the transformations. I’m using the following plugins:

  • remark-gfm to support GitHub Flavored Markdown.
  • 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.

contentlayer.config.ts

+ 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,
    ],
  },
})

Contentlayer Applications

There are more things we can do with the data.

Application #1: RSS Feed

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 }))

Application #2: XML Sitemap

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="http://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)

Run both scripts after building the project and automatically generate a new RSS feed and sitemap.

In package.json, add:

"scripts": {
+    "sitemap": "node scripts/sitemap.mjs",
+    "rss": "node scripts/rss.mjs",
+    "postbuild": "yarn sitemap && yarn rss",
  },

Final Thoughts

Building Static sites with Next.js becomes so effortless with MDX and Contentlayer.

MDX combines with remark and rehype ecosystem enriches the possibility of building consistent pages with Markdown. Contentlayer makes data and content in MDX files available to consume in Next.js projects.

If you’re exploring ways to build your own static sites, check out the libraries mentioned earlier. It not only reduces the time to market but also is a lot of fun to build!🦄


This article is originally posted on Daw-Chih’s website.

Comments

Signup or Login to Join the Discussion

Tags

Related Stories