--- ## 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](https://www.netlify.com/blog/2020/04/14/what-is-a-static-site-generator-and-3-ways-to-find-the-best-one)Ā (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](https://nextjs.org/)Ā project setup in order to: \ * Publish a new article * Estimate reading time * UpdateĀ [the list of articles](https://dawchihliou.github.io/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: \ ```bash 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](https://nextjs.org/docs/advanced-features/using-mdx)Ā recommended by Next.js. I was usingĀ [@mdx-js/loader](https://github.com/mdx-js/mdx/tree/main/packages/loader)Ā andĀ [@next/mdx](https://www.npmjs.com/package/@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` ```javascript 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` ```typescript 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](https://developers.google.com/search/docs/advanced/sitemaps/overview)Ā 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](https://leerob.io/)Ā and found out he was using a alpha library calledĀ [Contentlayer](https://github.com/contentlayerdev/contentlayer)Ā to solve the problem. ## What is Contentlayer [Contentlayer](https://github.com/contentlayerdev/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](https://nodejs.org/api/esm.html#modules-ecmascript-modules). \ 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](https://dawchihliou.github.io/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](https://mdxjs.com/guides/frontmatter/)Ā support with custom parsers. You can express the meta data like this: \ ```yaml --- 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](https://dawchihliou.github.io/#what-was-the-problem)Ā 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: \ ```bash 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](https://nextjs.org/docs/routing/dynamic-routes)Ā `[slug].tsx`. We'll use this page to statically generate the blog pagesĀ [later](https://dawchihliou.github.io/#using-contentlayer-data-for-static-site-generation). ### Configuring Contentlayer Contentlayer offers seamless integration with Next.js. \ To install the dependencies: \ ```bash yarn add contentlayer next-contentlayer ``` \ Contentlayer reads the configuration fromĀ `contentlayer.config.ts`. Let's create one. \ ```bash 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` ```typescript 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` ```javascript 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` ```typescript 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](https://github.com/remarkjs/remark)Ā is an awesome plugin ecosystem that transforms markdown. * [rehype](https://github.com/rehypejs/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: \ ```javascript 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](https://github.github.com/gfm/). * `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 ```typescript + 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` ```javascript 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` ```javascript 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: \ ```json "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](https://dawchihliou.github.io/articles/build-better-nextjs-static-sites-with-mdx-and-contentlayer).