Makuhari Development Corporation
12 min read, 2229 words, last updated: 2023/12/18
TwitterLinkedInFacebookEmail

Write blogs with MDX in Nextjs

We are building a blog system with

  • zero config
  • non-English routing
  • frontmatter auto-settle metadata

using React, Typescript, Tailwindcss, and Next AppRouter.

First Step

Let's try the Next MDX support first, we will go into customized handling in later chapters.

Follow the next docs

According to the docs of mdx

add the dependencies

yarn add @next/mdx @mdx-js/loader @mdx-js/react
yarn add -D @types/mdx

add mdx-components.tsx to the same layer under the src folder (with the app folder if have).

src
- app
- mdx-components.tsx

with the content as below

import type { MDXComponents } from 'mdx/types';
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return components;
  // Allows customizing built-in components, e.g. to add styling.
  // return {
  //   h1: ({ children }) => <h1 style={{ fontSize: "100px" }}>{children}</h1>,
  //   ...components,
  // }
}

update the next.config.js (to use plugins later on, change the config file to .mjs, with the content as below)

import withMDX from '@next/mdx';
 
const mdxConfig = {
  extension: /\.(md|mdx)$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
};
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
};
 
export default withMDX(mdxConfig)(nextConfig);

Tailwindcss plugin

We can simply use a tailwind plugin to style the MDX content, check the blog, and the docs of tailwind typography plugin

yarn add @tailwindcss/typography

config tailwind.config.ts

import type { Config } from 'tailwindcss';
 
const config: Config = {
  content: ['./src/app/**/*.{js,ts,jsx,tsx,mdx}'],
  plugins: [require('@tailwindcss/typography')],
};
export default config;

add the prose class to the nearest layout

<div className="prose prose-stone">
  {children}
</div>

Now, you should have a lovely styled rendering for your MDX content.

You could probably want to remove the default pseudo-element added for the <code /> and <blockquote /> content, you can adjust the tailwind.config.ts to achieve it.

const config: Config = {
  theme: {
    extend: {
      typography: {
        DEFAULT: {
          css: {
            'code::before': { content: '""' },
            'code::after': { content: '""' },
            'p::before': { content: '""' },
            'p::after': { content: '""' },
          },
        },
      },
    },
  },
  plugins: [require('@tailwindcss/typography')],
};

You can also choose not to use those styles for specific elements / pages by adding the class not-prose.

Other MDX plugins

To use MDX plugins, check the docs of mdx plugins, you can also publish your own plugins.

for exmaple

syntax highlighting: make your code pieces colorful
forntmatter: the prefix content for your markdown file, could be read/auto-generated via some tools

The mdxConfig in next.config.mjs would be something like

import withMDX from '@next/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
 
const mdxConfig = {
  extension: /\.(md|mdx)$/,
  options: {
    remarkPlugins: [remarkGfm, remarkFrontmatter],
    rehypePlugins: [[rehypePrettyCode, { theme: 'github-dark-dimmed' }], rehypeSlug],
  },
};

Going Further

Now we would have the file structure like below

layout.tsx
blog-a
- page.mdx
blog-b
- page.mdx
...

For a blog system, we are considering some points more

  • Currently, page.mdx always needs to be settled inside a folder that is used for the route, there would be too many page.tsx to write and we want to directly use the file name to route and place all the MDX files in a single folder, without any route config file.

  • In Next, the folder name could not contain non English words, and probably could not be solved by simply adding something like a middleware at this moment (2023/12), so we may want to write our own method to routing across MDX files to support like Japanese words.

  • We also want to read the MDX files' frontmatter to

    • set to the metadata to prevent write them twice or more in different places.
    • generate a blog list with the info more than only the title itself (like authors, update time, etc) without maintain a separate config file to store that info.

We are gonna solve them all later on, let's start from the separate concepts.

Read the folders contained in Nextjs SSR page

Starting with the previous file structure, inside layout.tsx or page.tsx, as long as they are server components and running on the server, we can actually use fs to read all the content inside a specific folder as below.

import fs from 'fs';
import Link from 'next/link';
import path from 'path';
 
// the relative path of a specific folder
const folderPath = path.join(process.cwd(), 'src/app/(contents)/posts');
const folders = fs.readdirSync(folderPath);
 
<div>
  {folders.map((name) => (
    <div key={name}>
      <Link href={`posts/${name}`}>{x}</Link>
    </div>
  ))}
</div>

Notice that the process.cwd() is used to get the current working directory of the node.js process.

This could be useful if you want to list your local MDX files as blog links.

Read the frontmatter info

We can use the plugin gray-matter to read the MDX string to get the frontmatter info (and remember to add plugin like remark-frontmatter in advance to make frontmatter works).

  • your-folder-name/page.mdx
---
title: 'your-mdx-title'
author: 'Tom'
create: '2024-01-01'
update: '2024-01-01'
---
 
export const metadata = {
  title: 'your-mdx-title', // still writing it twice here at this moment
  author: 'Tom'
};
 
# The Header
 
## The sub header
 
The content
  • page.tsx
import matter from 'gray-matter';
 
const filePath = path.join(
  process.cwd(),
  'src/app/(contents)/posts/your-folder-name/page.mdx',
);
const mdxContent = fs.readFileSync(filePath);
const { data } = matter(mdxContent);
console.log(data.title); // your-mdx-title
console.log(data.author); // Tom

Failed to dynamically read frontmatter then use it for parent layout

Without giving a specific relative path, we also want to read the folders with their inside MDXs' frontmatter info for parent layout, wherever we call the method.

  • layout.tsx
import { Metadata } from 'next';
import acquireFrontMatter from '../_utils/acquireFrontMatter';
import YourLayout from './YourLayout';
 
// some method to get the frontmatter info without pre-set the relative path
const { title } = acquireFrontMatter();
 
export const metadata: Metadata = {
  title: title,
};
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return <YourLayout title={title}>{children}</YourLayout>;
}

However, this won't work out because

  • since the layout only run once for the sub-routers, if we route though the MDX pages, the title actually won't change.
  • this need the acquireFrontMatter to dynamically get its' relative path, and we can't use someting like __filename to calculate it out as well.

So we are still far from not repeating ourselves from writing multiple page.mdx and metadata info, etc.

Switch to a self-implementation to render the MDX content with dynamic routes

This is the point we find out that it would be better to do it by ourselves to generate the MDX content out of the Next box.

Follow the docs of next dynamic routes, now we adjust our file structure as below

[file]
- blog-a.mdx
- blog-b.mdx
- blog-c.mdx
- page.tsx
- layout.tsx

Since we could get all the MDX info via gray-matter, let's directly try it out

  • acquireHTMLString.ts
import fs from 'fs';
import matter from 'gray-matter';
import path from 'path';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import rehypeStringify from 'rehype-stringify';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
 
/**
 * Read the MDX file then convert to HTML string for rendering
 * @param folder target folder's relative path
 * @param fileName MDX filename
 * @returns HTML string
 */
export default async function acquireHTMLString(folder: string, fileName: string) {
  const mdxContent = fs.readFileSync(path.join(process.cwd(), folder, `${fileName}.mdx`));
  const { content } = matter(Buffer.from(mdxContent));
  const result = await unified()
    .use(remarkParse) // Convert into markdown AST
    .use(remarkFrontmatter)
    .use(remarkGfm)
    .use(remarkRehype as any) // Transform to HTML AST
    .use(rehypeSlug)
    .use(rehypePrettyCode, {
      theme: 'github-dark-dimmed',
    })
    .use(rehypeStringify) // Convert AST into serialized HTML
    .process(content);
  return result.value;
}
  • page.tsx
const CONTENT_PATH = 'src/app/_contents/posts';
 
export default async function Page({ params }: { file: string }) {
  const HTMLString = await acquireHTMLString(CONTENT_PATH, params.file);
  return <div dangerouslySetInnerHTML={{ __html: HTMLString }} />;
}

We instantly faced a shikiji issue for code syntax highlighting, having the error

./node_modules/shikiji/dist/index.mjs
export 'addClassToHast' (reexported as 'addClassToHast') was not found in './core.mjs' (module has no exports)

solved according to this S.O. answer

via adding configs in next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.module.rules.push({
      test: /\.mjs$/,
      include: /node_modules/,
      type: 'javascript/auto',
    });
    return config;
  },
}

Now we can read a MDX file from anywhere then render it properly just as well as the previous page.mdx, as long as we get the path.

So next step would be the route settings inside new page.tsx.

Read the folder to get MDX files info for routes

Since we can read the frontmatter by files, we can now achieve the routes listing of the local MDX pages based on their frontmatter info.

for exmaple, read the title, sort via updateAt, divide via authors, etc...

  • checkoutMDXfiles.ts
import fs from 'fs';
import matter from 'gray-matter';
import path from 'path';
 
export interface MDXFileInfoItem {
  // name in file system
  fileName: string;
  // latest update time
  updateAt: Date;
  // title in frontmatter
  title: string;
  // description in frontmatter
  description: string;
  // relative path for routing
  href?: string;
}
 
/**
 * Read the specific folder to get all the MDX files' frontmatter info
 * @param folder target folder's relative path
 * @param routePath optional, target folder's route path
 * @returns files' info extend from frontmatter
 */
export default function checkoutMDXFiles(
  folder: string,
  routePath?: string,
): Array<MDXFileInfoItem> {
  const pathPrefix = process.cwd();
  const files = fs.readdirSync(path.join(pathPrefix, folder));
  const frontmatters = files
    .map((file) => {
      const mdxContent = fs.readFileSync(path.join(pathPrefix, folder, file));
      const { data } = matter(Buffer.from(mdxContent));
      const fileName = path.parse(file).name;
      return {
        fileName: fileName,
        updateAt: new Date(data.update),
        title: data.title,
        description: data.description,
        ...(routePath && { href: path.join(routePath, fileName) }),
      };
    })
    // the latest the front
    .sort((a, b) => b.updateAt.valueOf() - a.updateAt.valueOf());
  return frontmatters;
}
  • page.tsx
const CONTENT_PATH = 'src/app/_contents/posts';
const ROUTE_PATH = '/posts';
 
const posts = checkoutMDXFiles(CONTENT_PATH, ROUTE_PATH);

Set the dynamic routes

Now we can combine the above methods together.

  • page.tsx
import acquireHTMLString from '@/app/_contents/_utils/acquireHTMLString';
import checkoutMDXFiles from '@/app/_contents/_utils/checkoutMDXFiles';
 
interface PageProps {
  params: {
    file: string;
  };
}
 
const CONTENT_PATH = 'src/app/_contents/posts';
const posts = checkoutMDXFiles(CONTENT_PATH);
 
export async function generateStaticParams() {
  return posts.map((post) => ({
    file: post.fileName,
  }));
}
 
export async function generateMetadata({ params }: PageProps) {
  // decode params to support non English file name
  const post = posts.find((post) => post.fileName === decodeURI(params.file));
  const title = post?.title;
  const description = post?.description;
  return {
    ...(title && { title }),
    ...(description && { description }),
  };
}
 
export default async function Page({ params }: PageProps) {
  const HTMLString = await acquireHTMLString(CONTENT_PATH, decodeURI(params.file));
  return <div dangerouslySetInnerHTML={{ __html: HTMLString }} />;
}

Notice that we can simply put all params.file into decodeURI and then non English filename would be supported.

About the performance

If the MDX files & contents scale, we need to consider the performance impact.

By testing a sample file with 400 lines typical blog content, we can find out that read file & use gray-matter to get the content and frontmatter data won't cost much.

console.time('timer');
const mdxContent = fs.readFileSync(path.join(process.cwd(), folder, `${fileName}.mdx`));
const { content } = matter(Buffer.from(mdxContent));
console.timeEnd('timer');

✓ Compiled in 371ms (1763 modules)
timer: 0.862ms
timer: 0.766ms
timer: 0.628ms

but the parser converting MDX string -> markdown AST -> HTML AST -> HTML string makes a comparatively significant cost.

console.time('timer');
const mdxContent = fs.readFileSync(path.join(process.cwd(), folder, `${fileName}.mdx`));
const { content } = matter(Buffer.from(mdxContent));
const result = await unified()
  .use(remarkParse) // Convert into markdown AST
  .use(remarkFrontmatter)
  .use(remarkGfm)
  .use(remarkRehype as any) // Transform to HTML AST
  .use(rehypeSlug)
  .use(rehypePrettyCode, {
    theme: 'github-dark-dimmed',
  })
  .use(rehypeStringify) // Convert AST into serialized HTML
  .process(content);
console.timeEnd('timer');

✓ Compiled in 516ms (1735 modules)
timer: 1.144s
timer: 1.116s
timer: 1.197s

So, it seems OK to go over all the files, read all the frontmatters to build the static routes, or a list of blogs, but if we convert all the pages at once during build for the Full Route Cache, maybe it could be a problem, we could probably adjust the generateStaticParams to not declare the dynamic routes for the cache, or add a further policy.

Error handling

We can simply add an error.tsx to give a fallback for the non-existing routes.

posts
- [file]
- - layout.tsx
- - page.tsx
- error.tsx

with the content

'use client';
 
import ErrorPage from 'next/error';
 
export default function Error() {
  return <ErrorPage statusCode={404} />;
}

Final Thoughts

We can optionally move all the related dependencies to devDependencies as long as we are using SSG, also, if we don't write page.mdx anymore, we can remove the previous Next MDX related dependencies as well.

We could also create a Toc independently outside the rendering content reading the files' frontmatter, to give a float Toc for content header routing in each page.

Dependencies

Here are the used dependencies.

Name Remark
@tailwindcss/typography element rendering styles
@mdx-js/loader next mdx
@mdx-js/react next mdx
@next/mdx next mdx
@types/mdx next mdx
gray-matter read mdx content & frontmatter
rehype-pretty-code code block syntax highlighting
rehype-slug add id to headers
rehype-stringify serialize HTML AST
remark remark support
remark-frontmatter support frontmatter
remark-gfm github format markdown
shikiji code block syntax highlighting
Makuhari Development Corporation
法人番号: 6040001134259
サイトマップ
ご利用にあたって
個人情報保護方針
個人情報取扱に関する同意事項
お問い合わせ
Copyright© Makuhari Development Corporation. All Rights Reserved.