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 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!
              
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
                iframes, 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).↩︎