Using Next.js 14 to Render Markdown Data


June 21, 2024 Program

Using Next.js 14 to Render Markdown Data
Rendering Markdown data using Next.js 14 and react-markdown.

Introduction

🔗

To implement Markdown document rendering in Next.js 14, there are many packages available. After research, Contentlayer is commonly recommended for its lightweight and ease of use, but there is currently no version for Next.js 14. Therefore, the widely used react-markdown, along with gray-matter, rehype-highlight, rehype-raw, and remark-gfm, was chosen to implement basic MD rendering pages.

First, install the required packages:

bash
npm install react-markdown gray-matter rehype-highlight rehype-raw remark-gfm

Purpose Introduction:

react-markdown: Converts Markdown content into HTML elements.

gray-matter: Parses Markdown strings containing front matter, separating the front matter and Markdown content so that information from the front matter, such as titles, dates, tags, etc., can be used during rendering.

rehype-highlight: A Rehype plugin for adding syntax highlighting when rendering Markdown documents. It supports various programming languages and is essential for articles sharing code, as it automatically identifies and highlights code sections in the article.

rehype-raw: Also a Rehype plugin, used for handling raw HTML in Markdown. It allows HTML tags to be included and rendered in Markdown documents.

When writing Markdown documents, if you need to insert images and adjust their aspect ratio, it's not possible with pure Markdown syntax. When building websites with Hugo, I used shortcodes to handle this issue. After research, it's actually possible to embed HTML syntax directly in Markdown documents and then use rehype-raw for conversion and rendering, which is very fast and convenient.

remark-gfm: A Remark plugin for supporting GitHub Flavored Markdown (GFM) extended syntax. This includes tables, automatic links, strikethroughs, and more.


Implementation

🔗

Retrieving Original MD Files

🔗

The main functionality is to render existing Markdown documents. Since the original MD files were not stored in a single folder, I chose to retrieve all files recursively.

Next, use gray-matter to parse all files and return them.

Path: src\lib\posts.js

JS
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const postsDirectory = path.join(process.cwd(), 'posts');
// Recursive function to retrieve all Markdown files in a directory and its subdirectories
function getMarkdownFiles(dir, fileList) {
    const files = fs.readdirSync(dir);

    files.forEach((file) => {
        if (fs.statSync(path.join(dir, file)).isDirectory()) {
            fileList = getMarkdownFiles(path.join(dir, file), fileList);
        } else if (path.extname(file) === '.md') {
            fileList.push(path.join(dir, file));
        }
    });

    return fileList;
}
export function getSortedPostsData() {
    const isDev = process.env.ENV === 'dev';
    // Get file names under /posts
    //   const fileNames = fs.readdirSync(postsDirectory);
    // Retrieve all Markdown files including those in subdirectories
    const filePaths = getMarkdownFiles(postsDirectory, []);

    let allPostsData = filePaths.map((filePath) => {
        // Remove ".md" from file name to get id
        const id = path.parse(filePath).name;

        const fileContents = fs.readFileSync(filePath, 'utf8');

        // Use gray-matter to parse the post metadata section
        const matterResult = matter(fileContents);

        // Combine the data with the id
        return {
            id,
            filePath,
            ...matterResult.data,
        };
    });
    if (!isDev) {
        allPostsData = allPostsData.filter(post => post.draft != true);
    }
    // Sort posts by date
    return allPostsData.sort((a, b) => {
        if (a.date < b.date) {
            return 1;
        } else {
            return -1;
        }
    });
}

Detailed Explanation:

  1. const postsDirectory = path.join(process.cwd(), 'posts');
    Retrieves the path to the posts folder.

  2. const fileNames = fs.readdirSync(postsDirectory);
    If MD files are only placed in the posts layer, this can be used directly.

  3. getMarkdownFiles()
    Uses recursion to retrieve all Markdown files, including those in subdirectories, due to MD files being scattered across different subfolders.

  4. getSortedPostsData()
    To obtain the data for each article, including the front matter of the article.

  5. Additionally, isDev is used to determine if it's only in the testing environment, displaying data where draft is false (articles not yet intended to be published).

  6. Additionally, returning the filePath parameter because some of my file names are duplicates, so the path had to be used as an ID ultimately.

With the above, this function can be used to obtain the corresponding article data in the article list or article screen.

Next, we will explain how to render the content of MD documents.

Displaying Article Content

🔗

By using getSortedPostsData(), you can get all the data of the articles. The front matter can be displayed directly. To display the content, since it is in Markdown syntax, react-markdown is used to convert it into HTML elements.

Code

JS

export default async function Page({ params }: { params: { lang: Locale, category: string, post: string } }) {
    const { layout } = await getDictionary(params.lang)
    let post: any = getPostData(params.lang, params.category + "/" + params.post);
    const content = post.content;
    post = post.data;
    return (
        <article className="flex flex-col items-center justify-between min-h-screen max-w-full p-4">
            <div className="mdx max-w-screen-sm w-full break-normal">
                <h1 className="text-3xl font-bold my-6">
                    {post.title}
                </h1>
                <hr />
                <div className="pt-4 pb-4">
                    {post.description}
                </div>
                <ReactMarkdown
                    rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSlug]}
                    components={components}
                    remarkPlugins={[[remarkGfm, { singleTilde: false }], [remarkToc]]}
                >
                    {content}
                </ReactMarkdown>
            </div>
        </article>
    );
}

//The following are adjustments for specific conditions

const components = {
    img: CustomImage,
    iframe: CustomIframe,
    code: CodeBlock,
    h1: (props: any) => <CustomHeading level={1} {...props} />,
    h2: (props: any) => <CustomHeading level={2} {...props} />,
    h3: (props: any) => <CustomHeading level={3} {...props} />,
    h4: (props: any) => <CustomHeading level={4} {...props} />,
    h5: (props: any) => <CustomHeading level={5} {...props} />,
    h6: (props: any) => <CustomHeading level={6} {...props} />,
};



const CustomImage = ({ src, alt, title, width, link }: any) => {

    return (
        <div className="w-full flex flex-col items-center justify-center mt-3 mb-3">
            <div style={{ maxWidth: width, width: 'auto' }}>
                <Image src={src} alt={alt} title={title} width={800} height={800} />
            </div>
            <span>{title}</span>
            {link && <Link href={link}>Source</Link>}
        </div>

    );
};
const CustomIframe = ({ src }: any) => {
    return (
        <div className="max-w-full border-spacing-0 flex justify-center">
            <iframe src={src}
                width="100%"
                height="auto"
                allowFullScreen
                referrerPolicy="no-referrer-when-downgrade"
                loading="lazy">
            </iframe>
        </div>

    );
};

const CodeBlock = ({ node, inline, className, children, ...props }: any) => {
    const match = /language-([\w-#]+)/.exec(className || '');
    return !inline && match ? (
        <>
            <div className="bg-background px-2 py-1 text-sm font-mono">
                <div className="bg-gray-300 text-black inline-block rounded-md px-2">
                    {match[1]}
                </div>
            </div>
            <pre className={`${className} max-w-full overflow-auto rounded-md p-5`} {...props}>
                <code>{children}</code>
            </pre>
        </>

    ) : (
        <code className={`${className} break-words bg-gray-400 text-rose-700 rounded-md`} {...props}>
            {children}
        </code>

    );
};

const CustomHeading = ({ level, children }: { level: number, children: React.ReactNode }) => {
    const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements;
    let Fragment = "";
    if (typeof children === 'string') {
        Fragment = children as string;
        Fragment = Fragment.replace(/ /g, '-');
    }

    return (
        <>
            {/* Hidden anchor for scroll positioning /} 
            <div id={Fragment} className="relative block -top-20 invisible" />
             {/ Title with links */}
            <div className="flex items-center group space-x-2">
                <HeadingTag >{children}</HeadingTag>
                <Link href={Fragment && `#${Fragment}`}
                    className="mt-4 inline-flex h-6 w-6 items-center justify-center text-lg text-slate-400 no-underline opacity-0 transition-opacity group-hover:opacity-100 dark:text-slate-400 dark:group-hover:text-slate-300"
                    aria-label="Anchor"
                >
                    🔗
                </Link>
            </div>
        </>

    );
};

Detailed Explanation:

This code displays a specific article. Since my website includes multiple languages, there is a lang parameter that can be ignored for now. The main logic uses the category and the article title post to retrieve the content of a specific article.

getPostData() is a function located in src\lib\posts.js used to obtain the content of a specific article. The logic is simple: it retrieves all articles using getSortedPostsData() and then filters out the corresponding article based on the given conditions. As for performance, since I've decided to create SSG static pages for each article using generateStaticParams(), the actual website performance is very fast.

Next, just use

JS
<ReactMarkdown>
    {content}
</ReactMarkdown>

This allows for the rendering of Markdown content.

Of course, not all Markdown features can be rendered directly, including tables, automatic links, strikethroughs, or if there are code blocks, and the appearance does not match what you had in mind, you might consider extending functionality.

JS
<ReactMarkdown
    rehypePlugins={[rehypeHighlight, rehypeRaw, rehypeSlug]}
    components={components}
    remarkPlugins={[[remarkGfm, { singleTilde: false }], [remarkToc]]}>
        {content}
</ReactMarkdown>
  1. rehypePlugins
    Includes the code highlight extension rehypeHighlight, the conversion of HTML syntax contained in Markdown documents rehypeRaw, and automatically adds corresponding IDs to each Heading rehypeSlug.

  2. remarkPlugins
    Includes remarkGfm for displaying table styles, and remarkToc for automatically displaying a table of contents.

  3. components
    Allows for additional decoration of specific HTML to enhance aesthetics. You can see that I have made additional adjustments to content like images and iframes, but I will not describe the detailed usage in this article.

Conclusion

🔗

This article mainly explains how to retrieve MD files from the root directory and the process of rendering them with ReactMarkdown, as well as briefly describing how to adjust some appearances, hoping it helps you.

Next.jsReactWeb



Avatar

Alvin

Software engineer, interested in financial knowledge, health concepts, psychology, independent travel, and system design.

Related Posts