Create feature-rich markdown pages in next.js application
Introduction
For content oriented websites, writing content using markdown language is one of the most popular way.
There is also a superset of markdown called MDX, which allows us to write JSX in our markdown pages to make them interactive.
This markdown/mdx content can be in our local folder system of the project or it can be sourced from remote database.
There are various ways to integrate markdown/mdx content in next.js application based on how it is sourced.
In this tutorial, we are going to learn about:
- how to integrate MDX content in next.js application using mdx-bundler package. We are using
mdx-bundler
because it is more feature-rich and performant than other solutions at the time of writing this tutorial. - how to write content using github flavored markdown and render it in next.js application
- how to implement syntax highlighting feature for code snippets used in the markdown page
- how to use react components to style any HTML elements such as paragraph, table etc.
- how to integrate one off react component within our markdown page to make the page interactive but without impacting performance of other pages of web application
At the end of the tutorial, we will have a website with two web pages, which uses MDX to write content and uses all the features mentioned above.
Demo of this sample website is as follows:
Let's start step-by-step and learn about how to render MDX pages in next.js app using mdx-bundler package.
1. Scaffold next.js app
Scaffold next.js application by executing below command:
npx create-next-app test-mdx -e https://github.com/shripalsoni04/nextjs-mdx-bundler-integration-example/tree/initial-version
Here, we are scaffolding new next.js application by using starter example created by me. I created this example to have a quick starting point for this tutorial.
This starter example is created based upon default scaffolded next.js application with below mentioned changes:
- It has styled-components configured for styling components.
- It has
content/articles/
folder with two filesarticle-1.mdx
andarticle-2.mdx
- It has
pages/articles/[slug].js
page, which reads articles fromcontent/articles/
folder and creates two pages/articles/article-1
and/articles/article-2
.
Currently pages are showing the MDX content as text because we have not yet integratedmdx-bundler
, which parses this MDX content and generates HTML. pages/index.js
is cleaned up to show only two links to the articles.- Removes all other unnecessary code.
For more details, you can check the actual code different.
After scaffolding the application, it looks as shown below:
2. Integrate mdx-bundler
Now to parse our MDX text content and create bundle with all the dependencies of that MDX content, we need to first install mdx-bundler
package.
npm install mdx-bundler
mdx-bundler
provides bundleMDX(textContent, config)
method, which accepts MDX content as text and other configs. As this method accepts the MDX content as text, the MDX content can be in local file system or in remote database.
mdx-bundler
also provides bundleMDXFile(filePath, config)
method, which accepts file path instead of text content. So, when content is in local file system, we can directly use bundleMDXFile
method and mdx-bundler
will read file for us.
Both bundleMDX
and bundleMDXFile
methods parses the content and returns react code
and frontMatter
.
As these methods return frontMatter
, we do not need to explicitly use gray-matter
package to get frontMatter
.
So, let's use bundleMDXFile
as shown below:
import * as fs from 'fs'; import styled from 'styled-components'; import path from 'path'; import { bundleMDXFile } from 'mdx-bundler'; const ARTICLES_PATH = path.join(process.cwd(), 'content', 'articles'); export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const { code, frontmatter } = await bundleMDXFile(articlePath); return { props: { frontMatter: frontmatter, code } } }
Now, we can use code
returned from bundleMDXFile
method in our react component using getMdxComponent
method provided by mdx-bundler/client
package as shown below:
import { getMDXComponent } from "mdx-bundler/client"; export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only if `code` is changed. So, wrapping it in `useMemo` hook. const MDXComponent = useMemo(() => { return getMDXComponent(code); }, [code]); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent /> </main> </Wrapper> ); }
Complete code of integrating mdx-bundler
is as shown below:
import * as fs from 'fs'; import styled from 'styled-components'; import path from 'path'; import { bundleMDXFile } from 'mdx-bundler'; import { getMDXComponent } from "mdx-bundler/client"; import { useMemo } from 'react'; const ARTICLES_PATH = path.join(process.cwd(), 'content', 'articles'); export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only if `code` is changed. So, wrapping it in `useMemo` hook. const MDXComponent = useMemo(() => { return getMDXComponent(code); }, [code]); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent /> </main> </Wrapper> ); } export function getStaticPaths() { const lstFileName = fs.readdirSync(ARTICLES_PATH); const paths = lstFileName.map(fileName => ({ params: { slug: fileName.replace('.mdx', '') } })); return { paths, fallback: false }; } export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const { code, frontmatter } = await bundleMDXFile(articlePath); return { props: { frontMatter: frontmatter, code } } } const Wrapper = styled.div` padding: 24px; `;
Now, when we run the project, we can see that the MDX text content is getting parsed by mdx-bundler
into HTML and rendered correctly as shown below:
You can check complete source code of this sample application till this step at step2-integrate-mdx-bundler branch.
Though we can see that it is not rendering table correctly and not converting text links to actual hyperlinks automatically.
Let's fix those issues in next step.
3. Enable github flavoured markdown
Table, auto-link etc. features are not supported by default markdown syntax. These are called Extended Syntax.
To enable such extended syntax, we need to use lightweight markup languages which are built upon markdown language. One such popular lightweight markdown language is GitHub Flavored Markdown (GFM) language.
To add support of GFM in our application, first we need to install remark-gfm
package.
npm install remark-gfm
After that we can add remark-gfm
as plugin in xdmOptions
of mdx-bundler
config as shown below:
import remarkGfm from 'remark-gfm'; export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const config = { xdmOptions(options) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm, ]; return options; } }; const { code, frontmatter } = await bundleMDXFile(articlePath, config); return { props: { frontMatter: frontmatter, code } } }
After implementing above changes, we can see that now table is rendering as expected and the text links are converted to real hyperlinks.
You can check complete source code of this sample application till this step at step3-enable-github-flavored-markdown branch.
4. Implement code syntax highlighting
In above video, we can see that the code in article-2 is rendered as simple black text. It is better to have syntax highlighting feature to show such code snippets for better UX.
To implement syntax highlighting feature, we need to first install rehype-highlight
and highlight.js
plugins.
npm install rehype-highlight highlight.js
highlight.js is a library which provides syntax highlighting for so many different programming languages. It also provides plenty of themes to style syntax with different colors.
rehype-highlight is a rehype plugin for highlight.js
library, which tokenize the code snippet written in markdown and add all the necessary classes to the tokenized HTML. These classes are defined in any of the selected theme of highlight.js
. So when we use both these libraries, it results in nicely syntax highlighted code snippets.
To use rehype-highlight
plugin in our application, we need to configure it in xdmOptions
of mdx-bundler
configurations and import any highlight.js theme (here, we are using atom-one-dark
theme) in our GlobalStyles.js
file as shown below:
import rehypeHighlight from "rehype-highlight"; export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const config = { xdmOptions(options) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm, ]; options.rehypePlugins = [ ...(options.rehypePlugins ?? []), rehypeHighlight, ]; return options; } }; const { code, frontmatter } = await bundleMDXFile(articlePath, config); return { props: { frontMatter: frontmatter, code } } }
import { createGlobalStyle } from "styled-components"; import 'highlight.js/styles/atom-one-dark.css'; const GlobalStyles = createGlobalStyle` ... `
Similar to highlight.js
, there is another popular syntax highlighting library called prism.js. You can use rehype-prism and prism.js libraries if you want to implement syntax highlighting using instead of
highlight.js
.
After implementing above changes, we can see that now code snippet in article-2 is showing with nice syntax highlighting.
You can check complete source code of this sample application till this step at step4-add-code-highlighter branch.
5. Style paragraphs and table
We can style/change any HTML element, generated by parsing markdown, using our custom React components.
Let say in our sample application, we want to change styling of paragraph and table to look it better.
For that first let's create react components for Paragraph and Table as shown below:
import styled from 'styled-components'; export default function Paragraph({ children }) { return ( <StyledParagraph>{children}</StyledParagraph> ) } const StyledParagraph = styled.p` font-size: 1.25rem; margin-bottom: 1.25em; margin-top: 0; line-height: 1.6; `;
import styled from 'styled-components'; export default function Table({ children }) { return ( <StyledTable>{children}</StyledTable> ) } const StyledTable = styled.table` margin-bottom: 30px; border-collapse: collapse; th, td { border: 1px solid black; padding: 8px; } `;
Here, we are just wrapping the content of elements inside styled components.
Now, we can use these react components to render paragraph and table by passing them to components
prop of MDXComponent
component as shown below:
import Paragraph from '../../components/Paragraph'; import Table from '../../components/Table'; const ARTICLES_PATH = path.join(process.cwd(), 'content', 'articles'); const contentComponents = { p: Paragraph, table: Table }; export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only if `code` is changed. So, wrapping it in `useMemo` hook. const MDXComponent = useMemo(() => { return getMDXComponent(code); }, [code]); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent components={contentComponents}/> </main> </Wrapper> ); }
After implementing above changes, we can see that now paragraph and table are rendering with our custom styles.
You can check complete source code of this sample application till this step at step5-implement-paragraph-component branch.
6. Integrate one-off React component
Let say we have a Celebrate
component as shown below, which showers confetti when we click on Celebrate button.
import styled from 'styled-components'; import JSConfetti from 'js-confetti' import { useEffect, useRef } from 'react'; export default function Celebrate() { const confettiRef = useRef(); useEffect(() => { confettiRef.current = new JSConfetti(); }, []); const handleClick = () => { confettiRef.current.addConfetti(); }; return ( <Wrapper> <CelebrationButton onClick={handleClick}>Celebrate</CelebrationButton> </Wrapper> ); } const Wrapper = styled.div` display: grid; place-content: center; `; const CelebrationButton = styled.button` color: #fff; background-color: ${props => props.theme.colors.accent}; padding: 16px 24px; border: 0; font-size: 2rem; cursor: pointer; border-radius: 5px; `;
We want to use this Celebrate
component only in /articles/article-2
page.
We are going to face few errors/warning while integrating Celebrate
component in article-2
page. So, if you want to jump directly to the solution, you can check the Complete Solution for this step.
If you want to understand the details about the solution of this step, then continue reading about this step.
As article-2
is an mdx
file, we can import this Celebrate
component in it and use it using JSX syntax as shown below:
--- title: Article 2 --- import Celebrate from '../../components/Celebrate'; This is second article. This article will contain interactive react component. ... ### One-off component <Celebrate />
Before running the application, first we need to install js-confetti
package as it is used in Celebrate.js
component.
npm install js-confetti
Now, when we run the application, we get below error:
../../components/Celebrate.js: 5:4: error: Unexpected "<"
This happens because, we are using jsx
inside Celebrate.js
file. mdx-bundler
uses esbuild
to create bundle for MDX pages and by default esbuild
try to parse content of .js
file using js
loader. So, now to resolve this error, we need to change loader for .js
files to jsx
in esbuild
config as shown below:
export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const config = { globals: { 'styled-components': 'styled' }, xdmOptions(options) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm, ]; options.rehypePlugins = [ ...(options.rehypePlugins ?? []), rehypeHighlight, ]; return options; }, esbuildOptions(options) { options.loader = { ...options.loader, '.js': 'jsx' } return options; } }; const { code, frontmatter } = await bundleMDXFile(articlePath, config); return { props: { frontMatter: frontmatter, code } } }
After implementing above changes when we run the application, we get another error:
../../node_modules/styled-components/dist/styled-components.cjs.js:1:23425: error: Could not resolve "stream" (use "platform: 'node'" when building for node)
To resolve this error, we could set options.platform = "node"
in esbuildOptions
as the error message suggests, but that will include whole styled-component
module in the bundle of this page.
As we are already using styled-component
for the whole application, when we load this page, it will download styled-component
library twice. Once from main
application bundle and another in article-2
page bundle. This is bad for performance.
To solve this issue, we can use globals
config as shown below:
import styled from 'styled-components'; export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only if `code` is changed. So, wrapping it in `useMemo` hook. const MDXComponent = useMemo(() => { return getMDXComponent(code, { styled }); }, [code]); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent components={contentComponents}/> </main> </Wrapper> ); } export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const config = { globals: { 'styled-components': 'styled' }, xdmOptions(options) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm, ]; options.rehypePlugins = [ ...(options.rehypePlugins ?? []), rehypeHighlight, ]; return options; }, esbuildOptions(options) { options.loader = { ...options.loader, '.js': 'jsx' } return options; } }; const { code, frontmatter } = await bundleMDXFile(articlePath, config); return { props: { frontMatter: frontmatter, code } } }
globals
config allows us to share dependencies between our main application bundle and the page bundles, so that the dependency gets loaded only once.
globals
is an object where key represents the import package name and value represents the keyName of object passed as globals
(second argument) to getMDXComponent
method.
Now, when we run the application, we will be able to see the Celebration button on page.
When we check the console after running the application, we will find below warning message:
Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks. You can only call Hooks at the top level of your React function.
This happens when we set styled
as globals in getMDXComponent
method and the getMDXComponent
method is called within useMemo
hook.
To solve this issue, we can use memoize-one
library instead of using react useMemo
.
memoize-one
keeps only last returned value in memory and executes function body only when any argument is changed. But, it is free from checks implemented in useMemo
hook.
So, let's install memoize-one
package from npm.
npm i memoize-one
Now, wrap getMDXComponent
method call in memoizeOne
instead of useMemo
as follows:
import styled from 'styled-components'; import memoizeOne from 'memoize-one'; const memoizedGetMDXComponent = memoizeOne((code, globals) => { return getMDXComponent(code, globals); }); export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only when code gets changed. So using memoize-one package. const MDXComponent = memoizedGetMDXComponent(code, { styled }); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent components={contentComponents}/> </main> </Wrapper> ); }
Now, when we run the application, we can see that there is no other error/warning in the console.
Complete Solution
Phew, we solved all the errors 😅 Complete solution of integrating one-off react component Celebrate.js
inside our article-2.mdx
page look as shown below:
import * as fs from 'fs'; import styled from 'styled-components'; import path from 'path'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from "rehype-highlight"; import memoizeOne from 'memoize-one'; import { bundleMDXFile } from 'mdx-bundler'; import { getMDXComponent } from "mdx-bundler/client"; import Paragraph from '../../components/Paragraph'; import Table from '../../components/Table'; const ARTICLES_PATH = path.join(process.cwd(), 'content', 'articles'); const contentComponents = { p: Paragraph, table: Table }; const memoizedGetMDXComponent = memoizeOne((code, globals) => { return getMDXComponent(code, globals); }); export default function ArticlePage({ frontMatter, code }) { // From performance perspective, it is better to create new MDXComponent only when code gets changed. So using memoize-one package. const MDXComponent = memoizedGetMDXComponent(code, { styled }); return ( <Wrapper> <h1>{frontMatter.title}</h1> <main> <MDXComponent components={contentComponents}/> </main> </Wrapper> ); } export function getStaticPaths() { const lstFileName = fs.readdirSync(ARTICLES_PATH); const paths = lstFileName.map(fileName => ({ params: { slug: fileName.replace('.mdx', '') } })); return { paths, fallback: false }; } export async function getStaticProps({ params }) { const { slug } = params; const articlePath = path.join(ARTICLES_PATH, `${slug}.mdx`); const config = { globals: { 'styled-components': 'styled' }, xdmOptions(options) { options.remarkPlugins = [ ...(options.remarkPlugins ?? []), remarkGfm, ]; options.rehypePlugins = [ ...(options.rehypePlugins ?? []), rehypeHighlight, ]; return options; }, esbuildOptions(options) { options.loader = { ...options.loader, '.js': 'jsx' } return options; } }; const { code, frontmatter } = await bundleMDXFile(articlePath, config); return { props: { frontMatter: frontmatter, code } } } const Wrapper = styled.div` padding: 24px; `;
--- title: Article 2 --- import Celebrate from '../../components/Celebrate'; This is second article. This article will contain interactive react component. ... ### One-off component <Celebrate />
Now, when we run the application, we can see that we have a nice Celebrate component in /articles/article-2
page:
You can check complete source code of this sample application till this step at step6-add-one-off-component branch.
Conclusion
In this tutorial we learned about how to implement feature-rich interactive markdown pages in next.js application in performant way using mdx-bundler package.
You can explore complete source code of the sample application at this Github Repository. This repository has separate branches for each step explained in this tutorial.
You can see live demo of the sample application at this link
Do you have any question related to this post? Just Ask Me on Twitter