Bayan Bennett

Adding Metadata Using Markdown Frontmatter in NextJS

Next.js
JavaScript
Accompanying video
recent updates component

I recently made a component that displays the recent updates to reference.bayanbennett.com. My first attempt at doing it was absolutely the wrong way of doing it. The purpose of this post is to help others avoid this mistake.

File Timestamps Not Tracked by Git

files with timestamps

Using the time that the file was modified works if the website is being served locally but doesn't work if the CI/CD being used is pulling from git on every build. Some services use a cache, but this would not be a reliable source for determining the time modified.

If git doesn't store the file timestamps, where can it be stored? There are many different options, including moving to a database. I opted for the easiest, markdown frontmatter. The markdown files that generate the pages on my site won't be updated very often. If necessary, I could eventually add a pre-commit hook that would update the time modified.

What is Markdown Frontmatter?

Markdown frontmatter is a fenced-off section at the head of a markdown file that is YAML/TOML formatted. Here's an example of a YAML formatted markdown frontmatter:

---
title: What Is Markdown Frontmatter
created: 2021-06-11T18:00:06.401Z
---
Here's the content.

The idea is that the frontmatter will be parsed and separated from the rest of the markdown file.

Parsing Using remark-frontmatterremark-parse-frontmatter

To parse the frontmatter I'm using two libraries that are part of the UnifiedJS family of utilities: remark-frontmatter and remark-parse-frontmatter. Both libraries are needed and do different things:

  • remark-frontmatter will separate the frontmatter as a yaml or toml node.
  • remark-parse-frontmatter will parse the yaml/toml into an object.

Once processed, the frontmatter will be available in .data.frontmatter on the vFile that was passed to the processor.

import unified from "unified";
import remarkParse from "remark-parse";
import remarkFrontmatter from "remark-frontmatter";
import remarkParseFrontmatter from "remark-parse-frontmatter";
import vfile from "vfile";

const unifiedRemarkProcessor = unified()
  .use(remarkParse)
  .use(remarkFrontmatter)
  .use(remarkParseFrontmatter);

const markdown = vfile(`
---
title: test
---
`;

const hast = await processor.run(
  processor.parse(markdown),
  markdown
);

console.log(markdown.data.frontmatter);

Note: a meta problem with this post/video - the following section should have gone in a separate post/video. I try to keep this in mind in the future.

Problem with react-markdown

The react-markdown library works well but doesn't take full advantage of NextJS's server-side rendering (SSR). Yes, the React is rendered to static HTML and then is hydrated when the page is rendered, but half of the processing can be done once at build time. The goal is to give less to the browser to process and reduce the time it takes to hydrate.

Deconstructing react-markdown

I dove into the react-markdown library to see how it worked, I found the react-markdown.js file and observed how they set up their unified processor. I could copy and paste the processor, but there was one function that was integral to the library which did some additional processing as well as mapping the React components to the Hypertext Abstract Syntax Tree (HAST) nodes.

const reactMarkdown = hastChildrenToReact(
  { options: { components }, schema: html, listDepth: 0 },
  hast
);

Instead of replicating this function I kept the react-markdown library, but just imported the hastChildrenToReact function and its dependencies.

My final processor looks like this:

const processor = unified()
  .use(remarkParse)
  .use(remarkFrontmatter)
  .use(remarkParseFrontmatter)
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeSlug)
  .use(rehypeAutolinkHeadings, { behavior: "wrap" });

Now, when the pages are built by NextJS, they run this processor and only forward the HAST and the parsed frontmatter to the page for hydration.

Using the Markdown Frontmatter

After passing the frontmatter and hast from getStaticProps, they can be accessed by the page props.

export const getStaticProps: GetStaticProps<
  JavaScriptPageTemplateProps,
  PathResult
> = async ({ params }) => {
  /* ... */

  const hast = (await processor.run(
    processor.parse(markdown),
    markdown
  )) as Root;

  const { frontmatter } = markdown.data as { frontmatter: Frontmatter };

  return {
    props: {
      hast,
      frontmatter,
      /* ... */
    },
  };
};
const JavaScriptPageTemplate: VoidFunctionComponent<JavaScriptPageTemplateProps> =
  ({ hast, frontmatter, /* ... */ }) => {
    const reactMarkdown = hastChildrenToReact(
      { options: { components }, schema: html, listDepth: 0 },
      hast
    );

    const { tags, title } = frontmatter;

    return (
      <>
        <Head>
          <title>
            {title}
          </title>
        </Head>
        <Typography variant="h1" align="center">
          {title}
        </Typography>
        {reactMarkdown}
        <footer>
          <Typography>Tags: </Typography>
          {tags.map((tag) => (
            <Chip key={tag} label={tag} />
          ))}
        </footer>
      </>
    );
  };

export default JavaScriptPageTemplate;

TL; DR

I embedded some metadata in my markdown files using the markdown frontmatter format. After parsing this data, I included it in my NextJS pages. I also optimized the hydration time by handling some of the markdown processing at build-time.

© 2022 Bayan Bennett