Create feature-rich markdown pages in next.js application

Last Updated: April 4, 2022

IntroductionLink to this heading

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:

Demo of sample application with feature-rich markdown pages

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 appLink to this heading

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 files article-1.mdx and article-2.mdx
  • It has pages/articles/[slug].js page, which reads articles from content/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 integrated mdx-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:

Video of appliation after scaffolding it from starter example

2. Integrate mdx-bundlerLink to this heading

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:

Demo of sample application after integrating mdx-bundler

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 markdownLink to this heading

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.

Demo of application after enabling github flavoured markdown plugin

You can check complete source code of this sample application till this step at step3-enable-github-flavored-markdown branch.

4. Implement code syntax highlightingLink to this heading

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

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.

Demo of sample application after enabling code 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 tableLink to this heading

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;
`;

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.

Demo of sample application after rendering paragraph and table with custom react components

You can check complete source code of this sample application till this step at step5-implement-paragraph-component branch.

6. Integrate one-off React componentLink to this heading

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 SolutionLink to this heading

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;
`;

Now, when we run the application, we can see that we have a nice Celebrate component in /articles/article-2 page:

Demo of sample application after adding one-off Celebrate component in article-2 page

You can check complete source code of this sample application till this step at step6-add-one-off-component branch.

ConclusionLink to this heading

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

Explore More:

Subscription Image
Subscribe & Learn

As a full-time content creator, my goal is to create a lot of quality content, which can be helpful to you in advancing in your web development career ✨

Subscribe to my newsletter to get notified about:

  • 🗒 Thorough articles, tutorials and quick tips
  • 🗞 Latest web development news
  • 📙 My upcoming front-end courses
  • 💵 Subscriber exclusive discounts

No spam guaranteed, unsubscribe at any time.

Loading Subscription Form...