Embeds with NextJS MDX and App Router

Getting GitHub Gists, LaTeX, and Tweet embeds on my blog.

7/24/2024  

If you're here just to see how I got my result, skip to the "Solution" section.

This article is a bit on the sillier side due to how I approached the problem, and how I ended up using it to procrastinate my other work. The problem is very simple to describe: I wanted my blog (this website!) to use SSG, and I wanted my Markdown files to be able to show code snippets. Originally, I was just getting the data on the server and passing a promise to a client component that would wait for the promise to resolve, and then passing it through Remark and Prism (not to be confused with Prisma, which I also used in this project). Obviously, passing a promise from server to client is a really, really stupid idea. Having realized that, and having noticed that SSG was faster and better for SEO, I decided to make my (mostly static) blog be SSG'd. Now, in order to do this, I needed to port my markdown rendering to the server.

My attempted solutions, in order, were:

1. Using Unified and Untold Horrors

Since I was already using Remark, I might as well try to use Unified, right? That was so bad. My code was vaguely something like

refractor.register(jsx); // (with all the other languages)
const getLanguage = (node: any) => {
const className = node.properties.className || [];
// (for brevity)
return null;
};
const rehypePrism = (options: any) => {
options = options || {};
return (tree: any) => {
// @ts-ignore
visit(tree, "element", visitor);
};
function visitor(node: any, index: any, parent: any) {
const lang = getLanguage(node);
let result;
parent.properties.className = (parent.properties.className || []).concat(
"language-" + lang
);
result = refractor.highlight(node.toString(), lang);
node.children = result;
}
};
const processor = unified()
.use(parse)
.use(remark2rehype)
// @ts-ignore
.use(rehypePrism)
// @ts-ignore
.use(rehype2react, {
createElement: createElement,
Fragment: React.Fragment
});
view raw unified.js hosted with ❤ by GitHub

Much of this code has been deleted to maintain brevity. Notice the anys all over the place? This code was largely adapted from a guide that was from 2019 (5 years is a lot in the NextJS ecosystem!) and in JavaScript. When GitHub Copilot and ChatGPT gave up on trying to figure out the anys and // @ts-ignores, I gave up too!

2. NextJS MDX and mdx-embed

In the end, I settled on using NextJS's MDX to compile my blog pages — which is currently delivering this blog post. However, I didn't want to do Physics, so I decided I also needed to have the ability to embed things in my markdown. The first thing I wanted to do was make GitHub Gists embeddable (even though I had a way to display code already). This turned out to be very much not-easy. See, all of the guides I checked just pointed me to mdx-embed... which 1. uses ESM (so importing it was impossible) and 2. was last updated in 2022, meaning that the only way to install it was with --force — which is fine, if it didn't break everything — which mdx-embed did.

2.5 NextJS MDX

Of course, you may be thinking, "doesn't GitHub literally have a button called 'Embed' that gives you the embed code?" It does! The code it gives you is simple, just a script tag:

<script src="https://gist.github.com/borisnezlobin/91aa0b2c95d5e63264ee4da2d7649fc9.js"></script>

The thing is, I want my MDX to be compiled at build time, so changing the DOM from this script didn't work.

Solution

In the end, I used NextJS's MDX plugin and a custom component, GistEmbed, to get Gists. Getting gists to be displayed was a bit difficult because of the aforementioned "script doesn't run" issue. If you visit the script that loads, however, you'll find that it's actually quite simple:

document.write(/* link to github's CSS for Gists */);
document.write('<div>\n\n<span class="pl-c">\nwhatever\n</span>\n {omitted}</div>');
view raw gistEmbed.js hosted with ❤ by GitHub

So, the obvious thing to do is load the CSS on the website (we can steal Github's massive CSS file, then add some of our own code to the end of it), make a request to this JavaScript file, parse out the escaped characters, and then render it. Finally, we can modify the CSS file to get the look we want! Easy-peasy... ish. Reasoning through the Inception-style escaped characters took a while, but I ended up with the fun chain of RegEx replace calls you see here:

export const GistEmbed = async ({ gistId }: { gistId: string }) => {
const url = `https://gist.github.com/\${gistId}.js`;
console.log("Fetching gist", url);
const js = await fetch(url).then((res) => res.text());
// format is `document.write('{css}');\ndocument.write('{gist_html}');`
const writes = js.split("document.write('");
const gistHtml = writes[writes.length - 1].split("')")[0]
.replace(/(?<!\\)\\n/g, "")
.replace(/\\\\/g, "\\")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\`/g, "`")
.replace(/\\//g, "/");
return (
<div className="gist-embed">
<div dangerouslySetInnerHTML={{ __html: gistHtml}} />
</div>
);
}
view raw GistEmbed.tsx hosted with ❤ by GitHub

In case you're wondering, the usage for this is (in your MDX file, assuming you have next-mdx-remote and @next/mdx set up):

<GistEmbed gistId="borisnezlobin/22e4ed52cd37d9c14c34be049a41b6a5" />

If we try this right now, we'll get something vaguely Gist-looking but uglier. And, it won't change colors based on the preferred color scheme. Also, it'll have annoying margins, it won't look nice, and so on and so forth. This is where our CSS comes into play! I copied the <link rel="stylesheet" src="..."> from the first document.write() into gist.css and ran Prettier. After that, starting from line 2864, I started changing the more important styles.

body .gist, body .gist .gist-data {
@apply rounded-lg;
}
body .gist .gist-file {
margin-bottom: 0;
border: 1px solid;
@apply border-[#d4d4d4] dark:border-[#525252];
@apply rounded-lg;
}
body .gist .blob-wrapper {
border-radius: 0;
}
body .gist .highlight {
background-color: transparent;
font-size: 14px;
}
body .gist .highlight td {
padding: 5px 15px !important;
line-height: 1;
font-family: inherit;
font-size: inherit;
}
body .gist tr:first-child td {
padding-top: 15px !important;
}
body .gist tr:last-child td {
padding-bottom: 15px !important;
}
body .gist .blob-num {
@apply text-muted dark:text-muted-dark;
pointer-events: none;
}
body .gist .gist-meta {
display: none;
}
.my-2 { margin: 0; }
.gist-embed {
margin-bottom: 1rem;
}
.dark > body .gist .blob-code {
filter: brightness(177%) saturate(85%);
}
.dark {
.gist .pl-s,
.gist .pl-pds,
.gist .pl-s .pl-pse .pl-s1,
.gist .pl-sr,
.gist .pl-sr .pl-cce,
.gist .pl-sr .pl-sre,
.gist .pl-sr .pl-sra {
color: #874f39;
}
}
view raw gist.css hosted with ❤ by GitHub

I won't go over all of them here, but the more important ones are the two at the bottom. The first of those applies a filter for dark mode — this is because I was (unsurprisingly) too lazy to change the several hundred colors that GitHub gives me for light mode. So, I color shift to a more dark-mode friendly (brigher and less saturated = more pastel) color scheme. This messed up the string color, so I changed it to a VSCode-ish orange — except I had to "undo" the color filter to get my desired orange. That was fun!

The full gist.css can be found on GitHub.

That's not all.

Of course, if I got Gists to work, why not get other embeds to work as well? The two that came seemed like obvious choices were LaTeX\LaTeX embeds and Twitter (not X\mathbb{X}, it's just silly). Math embeds with react-latex-next were somewhat easier to implement (but still not easy!). For math, I had to do similar text-escaping shenanigans (that I won't go over too in-depth here), but it was basically just .replaceAll("\\", "\\\\") and replaceAll("{", "\\{") to stop React from trying to evaluate the contents inside of curly brackets as JS expressions. Tweets, on the other hand... wow. I didn't know it was possible to make embeds quite that bad. I have a whole thread-rant (on Twitter) about Twitter embeds:

But (after taking a few wrong turns, like trying to reverse-engineer the embed code), I found react-tweet, which solved the issue. It's unfortunate that I had to bring in two new dependencies for tweets and math, but I think that it turned out really nice (although, the tweet embeds are... a little scuffed, and the images don't load), and they definitely make this blog more interactive/information-y/whatever, words are difficult. YouTube embeds (should I ever want them), are just iframes, so they won't need any shenanigans like GitHub did.

Hopefully, this article has been of some use to you, or simply entertaining!

Liked this article?

More articles