How to Create a Super Minimal MDX Writing Experience

Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition

Published on
Sep 16, 2024

Read time
12 min read

Introduction

MDX is a fantastic tool for writing. It combines the ease-of-use of regular Markdown with the ability to drop in JSX components for more complex functionality.

But out-of-the-box MDX can be clunky. Without tailoring it to our needs, we can easily end up replacing writing time with coding time. If I’m writing, I don’t want to be taken out of the flow by unnecessary boilerplate, repetition or complexity: there’s a reason we’re using MDX and not HTML, after all!

What are we trying to avoid?

Let’s take a look at a common scenario: writing a blog post in a Next.js project. We want to use MDX to write our posts and we want to customise the layout of our posts with JSX components.

// src/app/blog/my-delicious-recipe/page.mdx
import MDX from "../../components/MDX";

export const metadata = {
  title: "My Delicious Recipe"
  alternates: {
    canonical: "/blog/my-delicious-recipe"
  },
};

<MDX.Hero
  slug="/blog/my-delicious-recipe"
  title="My Delicious Recipe"
  readTime="5 min read"
/>

<MDX.Main
  slug="/blog/my-delicious-recipe"
  title="My Delicious Recipe"
>

Lorem ipsum dolor sit amet.

</MDX.Main>

The problem here is that there’s a lot of repetition.

Some of this repetition is due to our own JSX components and some is due to how Next.js handles metadata. All in all, since the slug is also the name of the directory we’re in, this meant manually repeating /blog/my-delicious-recipe four times. The title is also repeated three times. And there’s a final irritation: we’re having to calculate a readTime and adding it in manually too.

A better approach

What if we could transform the example above into something cleaner, like below, without sacrificing the end result?

// src/app/blog/my-delicious-recipe/page.mdx
---
title: "My Delicious Recipe"
---

import MDX from "../../components/MDX";

<MDX.Hero />

<MDX.Main>

Lorem ipsum dolor sit amet.

</MDX.Main>

This is a lot easier to read. It removes the pretty-much all of the chores and leaves just the fun parts: writing and dropping in our JSX components. In this article, we’ll learn one approach to achieve this simplicity and save ourselves (and our colleagues) a lot of time and effort in the process.

How can we do this?

To achieve this, most of the work will need to be done via a custom Remark plugin. This allows us to alter the MDX before it is transformed to the HTML, CSS and JavaScript code that gets sent to the client.

The plugin needs to achieve three things:

  1. It should calculate the readTime, based on the word count of the MDX content.
  2. It should check for the presence of YAML front matter and, if found, pass it as a prop to a list of JSX elements.
  3. It should also use export the Next.js metadata object for the given page, based on the frontmatter.

Defining our types

Although our plugin needs to be written in JavaScript, it will be useful to define TypeScript types which we can use in the rest of our application. This will also make it clear what we’re working with.

First, we’ll define the FrontMatter type. This will be a TypeScript type that represents the front matter of our MDX content.

// src/lib/frontmatter.types.ts
export type FrontMatter = {
  url: string;
  title: string;
  subtitle: string;
  description: string;
  readTime: string;
  publishedAt: string;
  image: string;
  imageAlt: string;
  tags: string[];
};

We’ll also define a PropsWithFrontMatter type, which will be a React prop type that includes the front matter, which we can use to type the JSX components that will receive the front matter.

// src/lib/frontmatter.types.ts
import { PropsWithChildren } from "react";

// FrontMatter type as above

export type PropsWithFrontMatter<T = unknown> = PropsWithChildren<
  T & {
    frontMatter: Partial<FrontMatter>;
  }
>;

Creating a metadata function

Next, we’ll create a function that generates the metadata object for a given front matter. I think it’s a good idea to keep this function separate from the plugin itself, as it’s a self-contained piece of logic that can be reused elsewhere. That also means we can write it in TypeScript, since we can edit the AST tree to import this function automatically.

This schema is specific to Next.js’s Metadata type, and the set of fields I have chosen is not exhaustive, but it should be a good starting point.

// src/app/blog/generateMetadata.ts
import { Metadata } from "next";
import { FrontMatter } from "@/lib/frontmatter.types";

const SITE_NAME = "My Site";
const AUTHOR = "Joe Bloggs";
const AUTHOR_URL = "https://www.joebloggs.com";
const TWITTER_HANDLE = "@joe_bloggs";

export function generateMetadata({
  url = "",
  title = "",
  description = "",
  image = "",
  imageAlt = "",
  tags = [],
}: Partial<FrontMatter>): Metadata {
  const absoluteUrl = new URL(url, process.env.BASE_URL).toString();
  const absoluteImageUrl = new URL(image, process.env.BASE_URL).toString();

  return {
    title: title
      ? title.endsWith(SITE_NAME)
        ? title
        : `${title} | ${SITE_NAME}`
      : SITE_NAME,
    description,
    alternates: {
      canonical: absoluteUrl,
    },
    authors: [
      {
        name: AUTHOR,
        url: AUTHOR_URL,
      },
    ],
    keywords: tags.join(", "),
    openGraph: {
      title,
      description,
      url: absoluteUrl,
      type: "article",
      images: [
        {
          url: absoluteImageUrl,
          width: 1200,
          height: 630,
          alt: imageAlt,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      creator: TWITTER_HANDLE,
      title,
      description,
      images: [absoluteImageUrl],
    },
  };
}

Time to create the plugin itself!

Creating the plugin

First, let’s create a new file in our project, frontmatter.plugin.mjs. I like to store this in src/lib but you can put it wherever you like.

A Remark plugin is a function whose argument are the options we pass to it. This function returns another function that takes a tree and a file and modifies the tree before it is transformed to HTML. The tree is an abstract syntax tree (AST) representation of the MDX content (more on this below) and the file is a representation of the file itself.

The basic structure of our plugin will look like this:

const frontMatterPlugin = ({ jsxElementNames = [] }) => {
  return (tree, file) => {
    // our code will go here
  };
};

export default frontMatterPlugin;

Abstract Syntax Trees

If you haven’t dived into the guts of compilers—or tools like ESLint, Prettier, Webpack, Babel, and so on—you might not be familiar with the concept of an abstract syntax tree (AST). An AST is a tree representation of the structure of a program. In our case, the program is the MDX content.

In JavaScript, code is typically parsed into a type of AST called ESTree. This is a standardised format that allows tools to work with JavaScript code in a consistent way. For our purposes, an AST makes it much more straightforward to edit MDX file than it would be to manipulate the raw MDX content directly.

We won’t go into the details of ESTree here, but if you’re wondering how I know how to add, remove and edit nodes in the tree, it’s thanks to a combination of reading the ESTree spec and a generous helping of trial and error!

Installing dependencies

Before we continue, we need to install a couple of dependencies:

  • js-yaml will allow us to parse the YAML front matter.
  • estree-util-value-to-estree will help us convert JavaScript objects to ESTree nodes.
yarn add js-yaml estree-util-value-to-estree

Defining our helper functions

Next, we’ll define some helper functions.

Our most important helper function will be traverse. This function will allow us to walk the tree and apply a callback to each node. This is a common pattern in AST manipulation, allowing us to apply a function to each node in the tree recursively. We’ll use this to apply the front matter to the JSX elements.

const traverse = (node, callback) => {
  callback(node);

  if ("children" in node && Array.isArray(node.children)) {
    node.children.forEach((child) => traverse(child, callback));
  }
};

Next, we’ll define a function for our specific use case of calculating the read time. This function will count the number of words via the raw UTF-8 file string (accessible via file.value). Then, we’ll calculate the read time based on an average reading speed of 200 words per minute.

function calculateReadingTimeInMinutes(content) {
  const wordsPerMinute = 200;
  const words = content.trim().split(/\s+/).length;
  const minutes = words / wordsPerMinute;
  return Math.ceil(minutes);
}

Finally, I’ll add some helpers to check the types. (In TypeScript, this would be particularly handy, as we could use type guards to ensure the types are inferred correctly. Here, it’s just a bit cleaner.)

// Checks if the node is a horizontal rule, like ---
const isThematicBreak = (node) => node.type === "thematicBreak";

// Checks if the node is an ESM import or export statement
const isMdxjsEsm = (node) => node.type === "mdxjsEsm";

// Checks if the node is a JSX element, like <MDX.Hero />
const isMdxJsxFlowElement = (node) => node.type === "mdxJsxFlowElement";

The main plugin code

Now we can write the main plugin code. The code below will be written inside the returned function:

const frontMatterPlugin = ({ jsxElementNames = [] }) => {
  return (tree, file) => {
    // our code will go here
  };
};

Getting the URL

We’ll start by populating some front matter defaults. Namely, since the URL will be based on the file path, we can set that without needing to read the file, via the file.history.

In my setup, file.history is always an array with one element, which is the path to the file. But if your setup changes the file path, you may need to adjust this.

const url =
  "/" + file.history[0]?.split("/src/app/")[1]?.replace("/page.mdx", "");

let frontMatterData = {
  url,
  title: url,
};

Notice that we’ve also set the title to be the URL, as a fallback.

Removing front matter from the tree

Next, we’ll check if there is front matter in the MDX content. The YAML should be demarcated by --- at the start of the file. So if we don't find a thematic break at the start of the file, we can assume there is no front matter and return early.

if (!isThematicBreak(children[0])) {
  return;
}

Thanks to a quirk around how the YAML is parsed, we need to determine the end of the front matter either by:

  • finding a second thematic break or
  • by assuming the end of the front matter is the second node.

We can then remove the front matter and thematic breaks from the tree by splicing the array.

let secondThematicBreakIndex = children.findIndex(
  (node, index) => index > 0 && isThematicBreak(node)
);

if (secondThematicBreakIndex === -1) {
  // If no second thematic break is found, assume front matter ends at index 1
  secondThematicBreakIndex = 1;
}

// Remove front matter and thematic breaks from the tree
children.splice(0, secondThematicBreakIndex + 1);

Parsing the front matter

As for parsing the YAML front matter, it’ll be easier to do this by using the raw UTF-8 file string. It’s better to do this after checking the front matter exists via the AST, as that way we avoid unnecessary regex parsing.

We can access this via file.value. We’ll use regex to extract the frontmatter string, and—if unexpectedly we our regex doesn’t match—we can calculate the reading time based on the content and bail out early.

const match = file.value.match(/---\n([\s\S]*?)---\n([\s\S]*)/);

if (!match) {
  frontMatterData.readTime = `${calculateReadingTimeInMinutes(
    file.value
  )} min read`;

  return;
}

If the YAML is found, we’ll parse it with js-yaml and merge it with our defaults.

const [, rawFrontMatter, restOfContent] = match;

try {
  const yamlData = yaml.load(rawFrontMatter.trim());
  frontMatterData = {
    ...frontMatterData,
    ...yamlData,
  };
} catch (error) {
  file.message(`Error parsing YAML front matter: ${error.message}`);
  console.error(
    `Error parsing YAML front matter: ${
      error.message
    }. File history: ${file.history.join("\n")}`
  );
  return;
}

At this point, we can also calculate the read time based on the content of the MDX file.

const readTime = `${calculateReadingTimeInMinutes(restOfContent)} min read`;

frontMatterData.readTime = readTime;

Adding front matter to JSX elements

Now, we’ll traverse the tree and apply the front matter to any JSX elements that need it. We’ll check if the node is a JSX element and if the name of the element is in our list of elements that should receive the front matter. We’ll also add a wildcard option "*", in case we want to pass the front matter to all JSX elements.

If this is the case, we’ll add the front matter as a prop to the JSX element, via a new mdxJsxAttribute node.

Note that, before calling traverse, we create an estree node that represents the front matter as a JavaScript object, called programData.

const estreeFrontMatter = valueToEstree(frontMatterData);

const programData = {
  type: "Program",
  body: [
    {
      type: "ExpressionStatement",
      expression: estreeFrontMatter,
    },
  ],
  sourceType: "module",
};

traverse(tree, (node) => {
  if (
    isMdxJsxFlowElement(node) &&
    (jsxElementNames.includes(node.name ?? "") || jsxElementNames.includes("*"))
  ) {
    const mdxNode = node;

    mdxNode.attributes.push({
      type: "mdxJsxAttribute",
      name: "frontMatter",
      value: {
        type: "mdxJsxAttributeValueExpression",
        value: JSON.stringify(frontMatterData),
        data: { estree: programData },
      },
    });
  }
});

In our components, we can use our PropsWithFrontMatter to ensure types are inferred correctly.

export default function Hero({ frontMatter }: PropsWithFrontMatter) {
  return <section>{/* hero code goes here */}</section>;
}

Handling metadata

Finally, we can handle the metadata. We’ll start by finding the index of the last import or export statement in the tree. This will allow us to insert our import statement at the correct place.

const indexOfLastImportStatement = children.findLastIndex(
  (node) => isMdxjsEsm(node) && (node.value ?? "").trim().startsWith("import")
);

First, we’ll generate a declaration for both the import statement and the export statement. Bear in mind that source.value should be the path to the file that contains the generateMetadata function, which may be different depending on your project structure.

const importDeclaration = {
  type: "ImportDeclaration",
  specifiers: [
    {
      type: "ImportSpecifier",
      imported: { type: "Identifier", name: "generateMetadata" },
      local: { type: "Identifier", name: "generateMetadata" },
    },
  ],
  source: { type: "Literal", value: "../generateMetadata" },
};

const exportDeclaration = {
  type: "Program",
  body: [
    {
      type: "ExportNamedDeclaration",
      declaration: {
        type: "VariableDeclaration",
        declarations: [
          {
            type: "VariableDeclarator",
            id: {
              type: "Identifier",
              name: "metadata",
            },
            init: {
              optional: false,
              type: "CallExpression",
              callee: {
                type: "Identifier",
                name: "generateMetadata",
              },
              arguments: [estreeFrontMatter],
            },
          },
        ],
        kind: "const",
      },
      specifiers: [],
      source: null,
    },
  ],
  sourceType: "module",
};

Then we can create a node for each of these declarations:

const importNode = {
  type: "mdxjsEsm",
  value: `import { generateMetadata } from "../generateMetadata";`,
  data: {
    estree: {
      type: "Program",
      body: [importDeclaration],
      sourceType: "module",
    },
  },
};

const exportNode = {
  type: "mdxjsEsm",
  value: `export const metadata = generateMetadata(${JSON.stringify(
    frontMatterData
  )});`,
  data: { estree: exportDeclaration },
};

And at last, we can insert these nodes into the tree:

children.splice(indexOfLastImportStatement + 1, 0, importNode, exportNode);

That’s it! Our plugin is complete. Make sure to export it at the bottom:

export default frontMatterPlugin;

Adding the plugin to our Next.js configuration

All that’s left is to import the plugin into our next.config.mjs file and add it to the MDX configuration. For this example, I want to pass the front matter to the MDX.Hero and MDX.Main components, so I’ll add these to the jsxElementNames option. Edit this to suit your own components, or add a wildcard "*" to pass the front matter to all JSX elements.

import withMDX from "@next/mdx";
import frontmatterPlugin from "./src/lib/frontmatter.plugin.mjs";

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
  reactStrictMode: true,
};

export default withMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [
      [frontmatterPlugin, { jsxElementNames: ["MDX.Hero", "MDX.Main"] }],
    ],
  },
})(nextConfig);

Conclusion

That’s a wrap! We’ve created a super minimal MDX writing experience that allows us to focus on writing and drop in JSX components without worrying about boilerplate or repetition. We’ve also learned a bit about ASTs, how to write a Remark plugin, and how to integrate it into a Next.js project.

This may seem like a lot of work, but if you’re writing a lot of MDX content (or supporting others who are), the time saved by automating these tasks can quickly add up. The more you write, the more you’ll benefit from the simplicity and ease of use of your custom MDX setup.

For the full frontmatter.plugin.mjs file, see below:

// src/lib/frontmatter.plugin.mjs
import yaml from "js-yaml";
import { valueToEstree } from "estree-util-value-to-estree";

// Helper function to traverse nodes recursively
const traverse = (node, callback) => {
  callback(node);

  if ("children" in node && Array.isArray(node.children)) {
    node.children.forEach((child) => traverse(child, callback));
  }
};

function calculateReadingTimeInMinutes(content) {
  const wordsPerMinute = 200;
  const words = content.trim().split(/\s+/).length;
  const minutes = words / wordsPerMinute;
  return Math.ceil(minutes);
}

const isThematicBreak = (node) => node.type === "thematicBreak";
const isMdxjsEsm = (node) => node.type === "mdxjsEsm";
const isMdxJsxFlowElement = (node) => node.type === "mdxJsxFlowElement";

// This plugin checks for the presence of YAML front matter and passes it to predefined JSX elements, as well as exporting the Next.js `metadata` object for the given page.
const frontMatterPlugin = ({ jsxElementNames = [] }) => {
  return (tree, file) => {
    const children = tree.children;

    if (!isThematicBreak(children[0])) {
      return;
    }

    // Find the second thematic break indicating the end of front matter
    let secondThematicBreakIndex = children.findIndex(
      (node, index) => index > 0 && isThematicBreak(node)
    );

    if (secondThematicBreakIndex === -1) {
      // If no second thematic break is found, assume front matter ends at index 1
      secondThematicBreakIndex = 1;
    }

    // Remove front matter and thematic breaks from the tree
    children.splice(0, secondThematicBreakIndex + 1);

    const match = file.value.match(/---\n([\s\S]*?)---\n([\s\S]*)/);

    const url =
      "/" + file.history[0]?.split("/src/app/")[1]?.replace("/page.mdx", "");

    let frontMatterData = {
      url,
      title: url,
    };

    if (!match) {
      frontMatterData.readTime = `${calculateReadingTimeInMinutes(
        file.value
      )} min read`;

      return;
    }

    const [, rawFrontMatter, restOfContent] = match;

    try {
      const yamlData = yaml.load(rawFrontMatter.trim());
      frontMatterData = {
        ...frontMatterData,
        ...yamlData,
      };
    } catch (error) {
      file.message(`Error parsing YAML front matter: ${error.message}`);
      console.error(
        `Error parsing YAML front matter: ${
          error.message
        }. File history: ${file.history.join("\n")}`
      );
      return;
    }

    const readTime = `${calculateReadingTimeInMinutes(restOfContent)} min read`;

    frontMatterData.readTime = readTime;

    const estreeFrontMatter = valueToEstree(frontMatterData);

    const programData = {
      type: "Program",
      body: [
        {
          type: "ExpressionStatement",
          expression: estreeFrontMatter,
        },
      ],
      sourceType: "module",
    };

    traverse(tree, (node) => {
      if (
        isMdxJsxFlowElement(node) &&
        (jsxElementNames.includes(node.name ?? "") ||
          jsxElementNames.includes("*"))
      ) {
        node.attributes.push({
          type: "mdxJsxAttribute",
          name: "frontMatter",
          value: {
            type: "mdxJsxAttributeValueExpression",
            value: JSON.stringify(frontMatterData),
            data: { estree: programData },
          },
        });
      }
    });

    const indexOfLastImportStatement = children.findLastIndex(
      (node) =>
        isMdxjsEsm(node) && (node.value ?? "").trim().startsWith("import")
    );

    const importDeclaration = {
      type: "ImportDeclaration",
      specifiers: [
        {
          type: "ImportSpecifier",
          imported: { type: "Identifier", name: "generateMetadata" },
          local: { type: "Identifier", name: "generateMetadata" },
        },
      ],
      source: { type: "Literal", value: "../generateMetadata" },
    };

    const exportDeclaration = {
      type: "Program",
      body: [
        {
          type: "ExportNamedDeclaration",
          declaration: {
            type: "VariableDeclaration",
            declarations: [
              {
                type: "VariableDeclarator",
                id: {
                  type: "Identifier",
                  name: "metadata",
                },
                init: {
                  optional: false,
                  type: "CallExpression",
                  callee: {
                    type: "Identifier",
                    name: "generateMetadata",
                  },
                  arguments: [estreeFrontMatter],
                },
              },
            ],
            kind: "const",
          },
          specifiers: [],
          source: null,
        },
      ],
      sourceType: "module",
    };

    const importNode = {
      type: "mdxjsEsm",
      value: `import { generateMetadata } from "../generateMetadata";`,
      data: {
        estree: {
          type: "Program",
          body: [importDeclaration],
          sourceType: "module",
        },
      },
    };

    const exportNode = {
      type: "mdxjsEsm",
      value: `export const metadata = generateMetadata(${JSON.stringify(
        frontMatterData
      )});`,
      data: { estree: exportDeclaration },
    };

    children.splice(indexOfLastImportStatement + 1, 0, importNode, exportNode);
  };
};

export default frontMatterPlugin;

© 2024 Bret Cameron