This article is a part of the series "Creating a blog with Remix"
How to configure Remix and mdx-bundler (with file system access)
The MDX Bundler solution requires server-side file system access, and as such is not suitable for serverless application deployments using services like AWS Lambda or Netlify.
If you need a solution for a tool like Netlify, see this post.
This post outlines a process I took to improve this blog, although I ultimately abandoned this approach due to the requirement to have access to the file system.
Starting Point - The Old Way - Configuring via Remix's mdx
option.
Initially I had followed Remix's MDX guide.
We declare .mdx
files in our Remix routing structure. Remix's out-of-the-box tooling can handle the the MDX content and at build time converts it to JavaScript where it is treated just like any other ordinary React route component.
Conveniently, YAML frontmatter headers are also supported.
Some configuration is required - we declare Remark and Rehype plugins in the remix.config.js
.
Lines 13 to 23 in 69005aa
13 mdx: async (filename, ...rest) => {
14 const [rehypeHighlight, remarkToc] = await Promise.all([
15 import("rehype-highlight").then((mod) => mod.default),
16 import("remark-toc").then((mod) => mod.default),
17 ]);
18
19 return {
20 remarkPlugins: [remarkToc],
21 rehypePlugins: [rehypeHighlight],
22 };
23 },
The Problem
The problem I had is mostly around the usage of frontmatter - I had no control of how the frontmatter behaved with my application; I could only use the functionality as provided with the out-of-the-box tooling - adding titles, social meta tags etc.
One annoying issue I had was that twitter's social meta tags required their own twitter
prefix (eg. og:twitter:title
), even though this was going to be the exact same as the other socials, I had to go through and specially add frontmatter items for twitter on every post.
But mostly, I wanted to be able to group items in a series - and provide links to the next item in the series etc, all without having to maintain those links everywhere.
Note the nice series box at the top of this page.
Additional functionality might include including an author tag and creation dates.
The Solution
This solution is works for mdx-bundler@9.2.1. See the following issue:
The code blocks in this post come from this very simple Remix application repository here.
A recommended solution by Remix themselves is to use mdx-bundler.
At first I looked at how Kent C. Dodds' builds his blog. But his configuration is complex (eg. see here).
One of the top search results for 'remix mdx-bundler' gives us this Oldweb2 blog post, which is a lot simpler. However, this post is quite out of date. However, it was a useful starting point in understanding the general strategy of using mdx-bundler.
Move all of your MDX files out of the routes directory
You are no longer going to use Remix's out-of-the-box file routing for the blog posts. I moved all my posts from ~/app/routes/test/*.mdx
to ~/app/blog-posts/test/*.mdx
.
Update your TSConfig if you haven't already
Lines 10 to 11 in c5f14a0
10 "target": "ES2020",
11 "module": "ES2020",
The module
property will allow us to do dynamic imports later.
Declare .server.ts utility files
Lines 1 to 2 in c5f14a0
1export { readFile, stat, readdir } from "fs/promises";
2export { resolve } from "path";
Lines 1 to 2 in c5f14a0
1export { bundleMDX } from "mdx-bundler";
2
The .server.ts
file convention causes Remix to not include this code in client bundles - which is important as these use node packages.
Create a post.tsx utility for parsing the MDX.
Here we load our MDX files at run time and use MDX Bundler to parse them as components and as frontmatter.
Lines 1 to 101 in 8c0ae89
1import parseFrontMatter from "front-matter";
2import { readFile, readdir } from "./fs.server"
3import path from "path";
4import { bundleMDX } from "./mdx.server";
5
6// The frontmatter can be any set of key values
7// But that's not especially useful to use
8// So we'll declare our own set of properties that we are going to expect to exist
9export type Frontmatter = {
10 meta?: {
11 title?: string;
12 description?: string;
13 }
14}
15
16/**
17 * Get the React component, and frontmatter JSON for a given slug
18 * @param slug
19 * @returns
20 */
21export async function getPost(slug: string) {
22
23 const filePath = path.join(process.cwd(),'app', 'blog-posts', slug + ".mdx");
24
25 const [source] = await Promise.all([
26 readFile(
27 filePath,
28 "utf-8"
29 )
30 ]);
31
32 // Dyamically import all the rehype/remark plugins we are using
33 const [rehypeHighlight, remarkGfm] = await Promise.all([
34 import("rehype-highlight").then((mod) => mod.default),
35 import("remark-gfm").then((mod) => mod.default),
36 ])
37
38 const post = await bundleMDX<Frontmatter>({
39 source,
40 cwd: process.cwd(),
41
42 esbuildOptions: (options) => {
43 // Configuration to allow image loading
44 // https://github.com/kentcdodds/mdx-bundler#image-bundling
45 options.loader = {
46 ...options.loader,
47 '.png': 'dataurl',
48 '.gif': 'dataurl',
49
50 };
51
52 return options;
53 },
54 mdxOptions: (options) => {
55 options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkGfm];
56 options.rehypePlugins = [...(options.rehypePlugins ?? []), rehypeHighlight]
57 return options
58 }
59 });
60
61 return {
62 ...post,
63 frontmatter: {
64 ...post.frontmatter,
65 }
66 }
67}
68
69/**
70 * Get all frontmatter for all posts
71 * @returns
72 */
73export async function getPosts() {
74
75 const filePath = path.join(process.cwd(),'app', 'blog-posts', 'test');
76
77 const postsPath = await readdir(filePath, {
78 withFileTypes: true,
79 });
80
81 const posts = await Promise.all(
82 postsPath.map(async (dirent) => {
83
84 const fPath = path.join(filePath, dirent.name)
85 const [file] = await Promise.all([readFile(
86 fPath,
87 )
88 ])
89 const frontmatter = parseFrontMatter(file.toString());
90 const attributes = frontmatter.attributes as Frontmatter;
91
92 return {
93 slug: dirent.name.replace(/\.mdx/, ""),
94 frontmatter: {
95 ...attributes,
96 }
97 };
98 })
99 );
100 return posts;
101}
Create a slug handler
We now handle any requests for our blog posts with $.tsx
handler. We just pass the entire slug to our utility function, and ask it to return the component and frontmatter.
Lines 19 to 32 in 9afcfde
19export const loader: LoaderFunction = async ({ params, request }: DataFunctionArgs) => {
20 const slug = params["*"];
21 if (!slug) throw new Response("Not found", { status: 404 });
22
23 const post = await getPost(slug);
24 if (post) {
25 const { frontmatter, code } = post;
26 return json({ frontmatter, code });
27 } else {
28 throw new Response("Not found", { status: 404 });
29 }
30
31 return null;
32};
Lines 63 to 75 in 8c0ae89
63export default function Post() {
64 const { code, frontmatter } = useLoaderData<LoaderData>();
65 const Component = useMemo(() => getMDXComponent(code), [code]);
66
67
68 return (
69 <>
70 <PostHeader frontmatter={frontmatter} />
71 <Component />
72 </>
73 );
74}
75
Update any relative imports
Any relative imports may no longer work, as the imports will be relative to the application root, rather than the file doing the importing. So update them accordingly.
1import image from "app/assets/300.gif"
2
Update a pages metadata based on frontmatter
Your Remix meta function can use the frontmatter that the loader returned. Use this to update the page meta tags.
Lines 34 to 46 in 9afcfde
34export const meta: V1_MetaFunction = (arg) => {
35 const frontmatter = arg.data.frontmatter as Frontmatter;
36 const title = frontmatter.meta?.title ?? "Black Sheep Code";
37 const description = frontmatter.meta?.description ?? undefined
38
39 return {
40 title,
41 description,
42 "og:twitter:title": title,
43 "og:twitter:description": description,
44 };
45 };
46
Create a post header based on the post frontmatter
We can use the loaded frontmatter to create whatever other content (such as tags, author, post title) at the top (and/or bottom) of our post.
Lines 48 to 73 in 9afcfde
48function PostHeader(props: {
49 frontmatter: Frontmatter;
50}) {
51
52 const { frontmatter } = props;
53
54
55 // We can implement whatever we want here
56 return <>
57 {JSON.stringify(frontmatter, null, 2)}
58 </>
59}
60
61export default function Post() {
62 const { code, frontmatter } = useLoaderData<LoaderData>();
63 const Component = useMemo(() => getMDXComponent(code), [code]);
64
65
66 return (
67 <>
68 <PostHeader frontmatter={frontmatter} />
69 <Component />
70 </>
71 );
72}
73
Create home page list of blog posts
Here we get access the frontmatter across all posts to create a table of contents on our home page.
Lines 6 to 47 in c5f14a0
6export async function loader(data: DataFunctionArgs) {
7 const posts = await getPosts();
8 return posts;
9}
10
11function ListOfBlogPosts() {
12 const data = useLoaderData<Array<{
13 slug: string;
14 frontmatter: Frontmatter;
15 }>>();
16 return <>
17 {data.map((v) => {
18 return <BlogItem item={v} key={v.slug}/>
19 })}
20 </>
21}
22
23function BlogItem(props: {
24 item: {
25 slug: string;
26 frontmatter: Frontmatter;
27 }
28}) {
29
30 const {item} = props
31 return <div className= "blog-item">
32 <a href={`/test/${item.slug}`}> <h3>{item.frontmatter.meta?.title ?? item.slug} </h3></a>
33 <p>{item.frontmatter.meta?.description}</p>
34
35 </div>
36}
37
38
39export default function Index() {
40 return (
41 <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
42 <h1>Welcome to Remix</h1>
43 <ListOfBlogPosts/>
44 </div>
45 );
46}
47
And there you have it!
Questions? Comments? Criticisms? Get in the comments! 👇
Spotted an error? Edit this page with Github