Using Sass modules with MDX

Hopefully someone doesn't have to go on the expedition I went on to get it working

With v3 of my website (yup, I gave up on using the code-names) I decided to go with a completely new stack. I've never used any of the technologies before, and it was definitely fun to learn so many new things at the same time. Alas, one problem eluded itself from my reach, and only now have I figured out a good solution. That issue was getting Sass modules (.module.scss) to work when used in components that were imported into MDX files (.mdx).

Sass

I decided to use the Sass CSS preprocessor. In essence, that means I can write css with superpowers and end up with much cleaner code.

/* Normal CSS, no nesting, repeated code */
.card {
background: black;
}
.card:hover {
background: white;
}
.card img {
height: 100px;
}
// Yay, nesting!
.card {
background: black;
&:hover {
background: white;
}
img {
height: 100px;
}
}

Nesting is just one of the many features that Sass offers to improve your styles, and there are quite frankly just too many to cover. Here's a list of some I've used:

  • Color mixing
  • Sass variables
  • Nesting
  • Importing other Sass modules
  • Mixins
  • Exporting values that can be read with JS imports

MDX

I was initially planning on just using plain Markdown as I had before, and maybe write some new plugins, but then I stumbled across an enchanting blog post by Josh Comeau that had me sold on the wonders of MDX. It allows you to use JSX, aka React components, within Markdown. Woah 😲

Sounds awesome, right? Well, it wasn't too fun trying to implement it.

Attempt #1 - mdx-bundler

In the post, Josh mentions four of the most used packages to implement MDX:

He then goes on to explain why mdx-bundler is the best option, as next-mdx-enhanced is discontinued and @next/mdx and next-mdx-remote have irritating drawbacks. I locked my focus on using mdx-bundler, since it looked like the best option out of the four. The documentation was clear, and I wrote the entire blog system before stopping to test it. When I did test it with a dummy mdx with just text, it worked fine. When I brought in Sass modules is when the pain started.

Unlike Next.js, mdx-bundler doesn't have build-in Sass support that you can toggle by inserting one line into the config.

const path = require('path');
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
sassOptions: {
includePaths: [path.join(__dirname, 'src', 'styles')],
/* You put paths here that you want to enable Sass compiling for */
},
async headers() {
return [
{
source: '/:path*',
headers: [

Under the hood, mdx-bundler uses esbuild to compile the files to JSX. That output is then passed through a prop into a component, where it's converted into a prop and shown to the user.

// `code` is the value from bundleMDX
const Post = ({ code }) => {
const Component = useMemo(() => getMDXComponent(code), [code]);
return <Component />
}
// This would run on the server and compile the file
async function getSSRData() {
return {
// `someFile` is just the text from a .mdx file
code: await bundleMDX(someFile)
}
}
// Imagine this is a Next.js page
export default Page = () => {
return <Post code={await getSSRData()} />
}

Although Next.js was configured to handle Sass module imports, mdx-bundler's esbuild was not. I found two plugins for handling Sass imports with esbuild:

I tried out both options, and due to them having near identical names, couldn't pin the errors they gave to a specific plugin 😅

Disclaimer

Even though I couldn't get either to work with mdx-bundler, they're both awesome plugins and will work if configured properly.

I managed to get one of the two plugins to almost work the way I wanted. By having esbuild write to disk, putting the plugin above the other ones (I had put it at the end of the plugin array due to some errors I encountered that seemed to be due to plugin order), and a few more changes, (here's the GitHub issue I made, read Arcath's messages if you want to go down that route) esbuild wrote the compiled css file to disk, but never actually loaded the file when visiting the website.

const post = {
data: someFile,
frontmatter: { // Parsed from data above this snippet.
folder: 'demo' // Functions as the slug
}
}
const { code, frontmatter } = await bundleMDX(post.data, {
cwd: path.join(process.cwd(), 'blog', post.frontmatter.folder),
esbuildOptions: options => {
options.target = 'es2020';
// This got esbuild to work, but not the css loading
if (options.plugins) options.plugins = [
...options.plugins,
sassPlugin({
rootDir: '.'
}),
];
options.outdir = `public/css/`;
options.publicPath = `/css/`;
options.write = true;
return options;
}
});

Attempt #2 - @mdx-js/loader

After pondering about what I should do next with this website yesterday, I decided to give MDX another shot. If I couldn't get it to work, then oh well. There's always next time.

I found myself on the home page of the new MDX website, and found that the introductory guide was brimming with resources I'd never even heard of. And I had done a lot of prior research, only to find the crumbs of other souls who hadn't managed to figure it out.

MDX is rendered using next-mdx-remote - I really wanted to use Kent C Dodds' mdx-bundler which uses esbuild under the hood but I couldn't get it to play nicely with SCSS modules.

Amit Dhamu, https://amitd.co/colophon

But this guide was different. I had found the resource that had eluded me for weeks.

A screenshot of the navigation menu on mdxjs.com

There were a ton of community maintained modules, ranging from integrations with bundlers, compiler, site generators, you name it. I cherrypicked @mdx-js/loader from the list. I quickly wrote a proof of concept, a simple Next.js page that imported a .mdx file with a component dependent on Sass modules, rendered the component, held my breath... and it worked!

Great, now I had a working proof of concept. The next challenges came in the form of dynamically importing the pages and getting embedded metadata.

Dynamic imports

The simple page I had created was this:

import Page from '../../components/Page'
import Test from '../../../blog/rework/index.mdx'
const Changelog = () => {
return (<Page title='Changelog' subtitle='Here’s what I changed this release'>
<Test />
</Page>)
}
export default Changelog

This is fine and dandy for a standalone page, but I was using Next.js dynamic pages and needed the ability to dynamically import the module based on the folder name. I couldn't just put the input into the page function and add the folder name due to import not being available in functions, so I had to to look elsewhere. A search yielded a built-in Next.js module, next/dynamic, which was exactly what I was looking for. At first, I struggled to get it to work. I wasn't too sure of how it worked, and if it would even import the component for the initial render, but I figured it out and ended up with the following code:

import getPosts, { FrontMatter, UncompiledPost } from '../../lib/blog/getPosts'
import Page from '../../components/Page'
import pageStyles from '../../styles/pages/blogPage.module.scss'
import getPostBySlug from '../../lib/blog/getPostBySlug'
import dynamic from 'next/dynamic'
const Post = ({ frontmatter }: { frontmatter: FrontMatter }) => {
const Component = dynamic(() => import(`../../../blog/${frontmatter.slug}/index.mdx`));
return (<Page title={frontmatter.title} subtitle={frontmatter.subtitle} suppressHeader={true}>
<h1 className={pageStyles.title}>{frontmatter.title}</h1>
<h2 className={pageStyles.subtitle}>{frontmatter.subtitle}</h2>
<span className={pageStyles.content}>
<Component />
</span>
</Page>)
}

Now that I had that working, the posts were viewable. Except for one issue. When I was using mdx-bundler, the bundleMDX function returned the compiled code and an additional property for frontmatter data that it parsed from the file. However, when I imported a page using @mdx-js/loader with frontmatter data at the beginning of the file, it looked all ugly. This happened because the new loader treated the frontmatter data as markdown, whereas mdx-bundler had automatically removed it.

Note

I'm pretty sure this issue can be circumvented by using some option in next.config.js with @mdx-js/loader, but I didn't know at the time and I'm not sure what the option is and don't want to refactor at the moment.

To combat this, I replaced the frontmatter with simple exports and refactored some of the blog loading code

---
title: Rewriting my entire website
subtitle: For the third time, from scratch, with new technology I've never used before
published: '2021-11-20'
tags: ['website', 'boge']
slug: rework
thumbnail: icons.png
---
export const metadata = {
title: 'Using Sass modules with MDX',
subtitle: 'Hopefully someone doesn\'t have to go on the expedition I went on to get it working',
published: '2021-11-25',
tags: ['mdx', 'sass']
}

It's not as clean, but it works for me.

And that's a wrap! I know there's probably a better solution to some of the problems I faced, but this is my current setup for handling MDX. I might poke around with getting frontmatter to work, but I need to publish this post ASAP, as I have a few bugs I need to fix this release.

Cheers, Basil.

Nya!