Qwik MDX Frontmatter Menu

Qwik provides a handy way to create a menu based on the h1-h6 tags in a MDX file. This is useful for creating a table of contents for your page. You can also define a list of your MDX articles in a dedicated file and use this function to retrieve the data for the menu. The problem with this approach is that it does not give you the ability to get meta data from the MDX files. For this blog for example I used the date and the description in the MDX frontmatter for the lists meta data. This approach requires NodeJS fs package and is therefore not available in Vercel Edge Functions. It works very well with Qwik's static site generation.

Folder structure, component and MDX files

A route loader builds the bridge between the frontend and the backend in Qwik. It is just a function which you can execute in the Qwik component and it returns a signal. The signal is a reactive object which contains the data (if available). This signal can then be used in the template to render the menu. The route loader is executed on the server and therefore is capable of the NodeJS API.

Imagine we have a folder structure as follows and want to receive an array of the frontmatter data of the MDX files in the index.tsx file.

/src
  /routes
    /blog
      /index.tsx
      /my-first-post/index.mdx
      /my-second-post/index.mdx
      /my-third-post/index.mdx

Let's first create our component in the index.tsx file with the route loader "binding". We return an empty array in the first place.

import { component$, routeLoader$ } from '@builder.io/qwik';

export const useBlogDataLoader = routeLoader$(() => {
  return [];
});

export default component$(() => {
  const menu = useBlogDataLoader();
  return (
    <>
      <ul>
        {menu.value.map((item) => {
          return <li></li>;
        })}
      </ul>
    </>
  );
});

This creates an empty unordered list (ul tag).

The route loader

As mentioned, the route loader is a function which executes on the server and creates a signal on the frontend. Here we have access to the file system. So let's loop through the folders and detect the MDX files first. For this snippet I am using the routerLoader$ function from the previews example and enhance it as follows.

import { readdirSync } from 'fs';
import { join } from 'path';

export const useBlogDataLoader = routeLoader$(() => {
  const dir = readdirSync(join('src', 'routes', 'blog'), {
    withFileTypes: true,
  });
  const directories = dir.filter((dirent) => dirent.isDirectory());

  return [];
});

We still return an empty array at this point, so nothing will be rendered. Now it's time to actually work on the data. We want to get the frontmatter data of the MDX files. Therefore you should make sure that you have installed the front-matter package from npm. Now lets imagine the following MDX file

---
title: Qwik MDX Frontmatter Menu
date: 2023-03-24
description: Learn how to create a dataset based on your MDX files in your Qwik app.
---

# Qwik MDX Frontmatter Menu

This means that we can define the interface for the frontmatter data as follows. This can happen right before the definition of the route loader in the index.tsx file.

interface FrontmatterData {
  title: string;
  date: string;
  description: string;
}

Now there is one more thing we need to mention. The URL of the MDX file is not part of the frontmatter data. We create an interface which extends the FrontmatterData by an href attribute.

interface MenuItem extends FrontmatterData {
  href: string;
}

Now we are prepared to write a function which takes a filename and reads and returns the actual frontmatter data. In a function scope I prefer to use the lambda function notation. Note: At this point I comment out the parts which already have been discussed. You should not comment them out for the actual implementation.

import { readdirSync } from 'fs';
import { join } from 'path';

export const useBlogDataLoader = routeLoader$(() => {
  // const dir = readdirSync(join('src', 'routes', 'blog'), { withFileTypes: true });
  // const directories = dir.filter((dirent) => dirent.isDirectory());

  // Read the frontmatter data of the MDX files
  const readFileFrontmatter = (filename: string): MarkdownAttributes => {
    const file = readFileSync(
      join('src', 'routes', 'blog', filename, 'index.mdx'),
      'utf8'
    );
    const fileContents = fm<MarkdownAttributes>(file);

    return fileContents.attributes;
  };

  return [];
});

It would be a good practice to validate the data, for example with zod. But for this article we will skip this step. It would be a great homework for you if you want to learn more about data validation. Now we create the actual menu items with the url and return them.

export const useBlogDataLoader = routeLoader$(() => {
  // const dir = readdirSync(join('src', 'routes', 'blog'), { withFileTypes: true });
  // const directories = dir.filter((dirent) => dirent.isDirectory());

  // Read the frontmatter data of the MDX files
  // const readFileFrontmatter = (filename: string): MarkdownAttributes => {
  //   const file = readFileSync(
  //     join('src', 'routes', 'blog', filename, 'index.mdx'),
  //     'utf8'
  //   );
  //   const fileContents = fm<MarkdownAttributes>(file);
  //
  //   return fileContents.attributes;
  // };

  // Create the menu items with an href attribute
  const menuItems = directories
    .map((dirent) => {
      const markdownAttributes = readFileFrontmatter(dirent.name);
      return {
        href: `/blog/${dirent.name}`,
        ...markdownAttributes,
      } as MenuItem;
    })
    .sort((a, b) => b.date.getTime() - a.date.getTime());

  return menuItems;
});

Note that we loop through all the directories and read the frontmatter of the MDX files using the readFileFrontmatter function we have created earlier. Now we attach the href attribute. Note that the date attribute already is a Date object and we can use it to sort our blog posts by date. Last but not least we return the menu items instead of the empty array.

Build the menu in the template

In this section I won't focus on the styling. It shows how to loop through the elements and how you can access the properties we have defined in the MenuItem interface.

export default component$(() => {
  const menuItems = useBlogDataLoader();

  return (
    <ul>
      {menuItems.value.map((item) => {
        return (
          <li key={item.href}>
            <Link href={item.href}>
              <h1>{item.title}</h1>
              <p>{item.description}</p>
              <span>{item.date}</span>
            </Link>
          </li>
        );
      })}
    </ul>
  );
});

Enhancements

First of all you should think about data validation of your frontmatter data to detect errors early. You can use the zod package for this purpose. You can also think about client side functionality like filtering, sorting or pagination. Maybe I am going to cover this in a dedicated article in the future. Feel free to leave me a DM on Twitter or Linkedin if you have any questions.

Have an awesome day!

References