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
- remark-gfm: support github markdown format
- rehype-pretty-code: for syntax highlighting
- shikiji: for syntax highlighting
- remark-frontmatter: support frontmatter
- gray-matter: read the mdx content including the frontmatter
- rehype-slug: auto add id to headers
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 manypage.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 |