Getting GitHub Gists, LaTeX, and Tweet embeds on my blog.
Embeds with NextJS MDX and App Router
If you're here just to see how I got my result, skip to the "Solution" section.
Update Jun 16, 2025: I migrated my entire website to use HTML exports from Obsidian (which in itself merits another article, but at the moment I don’t have the time). As such, all of the embeds you see below will not work (I’ve included them as images for posterity, in case you need to use embeds with NextJS MDX and force SSG). Instead, they are static HTML. Below is the article as originally published (but this time with HTML and not… that funny episode).
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[1] 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.
So let’s go over how funny that was.
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); // (and other languages)
const getLanguage = (node: any) => {
const className = node.properties.className || [];
// (for brevity; in reality this returned properly)
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
});
Notice the any
s 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 any
s and
// @ts-ignore
s, I gave
up too!
mdx-embed
In the end, I settled on using
NextJS's MDX
to compile my blog pages — which is currently[2]
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.
Of course, you may be thinking, "doesn't GitHub literally have a
button called 'Embed' that gives you the embed code?" It does!
Here it is:
The whole code in that box is simple, something like
<script src="https://gist.github.com/borisnezlobin/91aa0b2c95d5e63264ee4da2d7649fc9.js"></script>
The thing is, MDX is compiled at build time, so changing the DOM from this script didn't work.
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>');
// ^ basically a bunch of HTML that you can display
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>
);
}
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[3]):
<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;
}
}
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.
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
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
iframe
s, so they won't
need any shenanigans like GitHub did.
Hopefully, this article has been of some use to you, or simply entertaining!
I really hope I never have the audacity to do something like that again.↩︎
Not anymore, but I’ve kept the article in original form↩︎
Full code. This link may die as I actively work on my website — feel free to dig into the history (as of June 16, 2025, this file exists).↩︎