Aug 01, 2021

Multi-Stage Markdown Processing with remark

Markdown is a great format to write formatted content, be it blog posts, documentation, or anything else that comes to mind. With support for interactive elements such as media, code blocks, and even LaTeX, the vast ecosystem provides all tools you need to get everything running.

Built on micromark, a streamlined Markdown parser, the remark project offers a suite of tools and plugins to convert plain Markdown text into an intermediate data structure, an abstract syntax tree, to process it programmatically, applying plugins is just one use case.

A common flow is to parse Markdown, apply your plugins, and convert it to HTML on the server or React in the browser, so you can even render interactive components. Sometimes, though, you might even want both: What if we could have server-rendered HTML that is made interactive on the client?

A multi-stage approach can make sure our content is mostly ready and just needs minimal work to render interactively.

In the following, I'll use a couple of remark-related packages. As these were recently upgraded to use ES modules, If you want to add this code into a Next.js application and do not find a way to support those, make sure to use a previous version that works with CommonJS.

Stage 1: Markdown to HTML

In the first stage, we'll take our Markdown content, process it with some plugins, and transform it to HTML, which we can send to the client. If you have a lot of content or many plugins and transformations, doing this work on the server or while pre-rendering a static site, this is perfect.

import unified from 'unified';
import remarkToRehype from 'remark-rehype';
import parseMarkdown from 'remark-parse';
import rehypeStringify from 'rehype-stringify';

export async function markdownToHtml(content: string) {
  const res = await unified()
    // Parse Markdown from string source
    // ... process the AST further with some plugins
    // Finally convert Markdown AST to HTML AST
    .use(remarkToRehype, { allowDangerousHtml: true })
    // And stringify to HTML

  // Returns HTML representation
  return res.contents as string;

Some plugins are restricted to work primarily in server environments as well, in which case this method works.

Stage 2: Interactive Components

We could just render our generated HTML, but if we need interactivity for specific elements, or want to reuse components we already have on the client, we can parse the HTML we got from the server once more and turn some elements into React nodes.

import React from 'react';
import unified from 'unified';
import rehypeToReact from 'rehype-react';
import rehypeParse from 'rehype-parse';

export function createReactFromMarkdown(content: string) {
  const { result: processedMarkdown } = unified()
    // Parse AST from HTML
    .use(rehypeParse, { fragment: true })
    // Apply custom rules, render interactive React components
    .use(rehypeToReact, {
      createElement: React.createElement,
      components: {
        p: (props: unknown) => <p {...props} className="my-4" />
        // ...

  return processedMarkdown as Element;

Passing in our HTML content into the function returns React elements that you can integrate into your component tree.

While I haven't found any issues with this approach, larger content may theoretically require some time to be parsed, transformed, and rendered, so if you are unable to render React on the server (e.g. with static site generation in Next.js) but have to do it on the client just in time, you might want to be cautious.

With this solution, we offloaded most of the processing clearly to a server part that is independent of any framework-specific settings (i.e. works everywhere). Our client can either just take the generated HTML or add some interactivity in the second stage.

For most projects where you handle static content, this can reduce time spent on writing complex pages as you can just write Markdown instead, and either use existing plugins or write your own.