The terrible things I did to Astro to render MDX content in my RSS feed
Astro is great at RSS and Astro is great at MDX, but Astro MDX in RSS is a whole different beast. Here's the shameful way I tamed it.
I’ve been thinking a lot about RSS lately.
This site has had RSS for a while now, but it wasn’t linked from anywhere until very recently. And it was only showing a short preview of the post instead of the full content, which I didn’t like but I didn’t have a way to turn my MDX blog posts into sanitized HTML for the RSS feed.
Astro and RSS
Astro support for RSS is great. They’ve got an integration for it, and the setup is delightfully straightforward if you’re writing in Markdown:
import rss from '@astrojs/rss';import type { AstroConfig } from 'astro';import { getCollection } from 'astro:content';import sanitizeHtml from 'sanitize-html';import MarkdownIt from 'markdown-it';
const parser = new MarkdownIt();
export async function GET(context: AstroConfig) { const blog = await getCollection('blog');
return rss({ title: 'My Blog RSS Feed', description: '...', site: context.site, items: blog .map((post) => { return { title: post.data.title, pubDate: post.data.pubDate, description: post.data.description, link: `/blog/${post.slug}`, content: sanitizeHtml(parser.render(post.body)), }; }), });}
Unfortunately, this does not work for MDX.
Astro, MDX, and RSS
At the time of writing, there’s no formal solution for getting MDX blog posts into a format that works for RSS in Astro. (There’s hope, though — check the resources at the end of this article for details.)
MDX is complicated. It’s part Markdown, part JSX, and getting it to do something new usually means digging into the abstractions it’s built on.
But even with that, the utility of MDX is hard to beat: I want to write mostly Markdown until I have something component-ey, and they I want to sprinkle in JSX for that.
I use it for things like images in <figure>
tags with captions, adding callouts, and embedding videos or CodePens into posts.
Astro has a first-class integration with MDX, but that integration doesn’t have anything added to support RSS.
Until now, I was living with the trade-off of only showing a short excerpt in RSS. But I’m not one to shy away from making a giant mess, so I decided, “Why not? Why shouldn’t I build it?”
The crimes against code I committed to get RSS and MDX working together in Astro
My MDX files use Astro components, which is very cool but also means that none of the tools out there for MDX work. They all assume React (or at least JSX-style) components.
First attempt: use MDX libraries (failed)
My first instinct was to use the MDX APIs directly, but I had a lot of trouble parsing the docs and community discussions pointed back to the docs I didn’t understand, so I decided that was probably not the right path for me to explore.
Second attempt: use mdx-bundler directly (failed)
Then I found mdx-bundler
by Kent C. Dodds. Kent always writes awesome docs, so I was pretty confident about this one. It promises to take in a string (what would be the the contents of the MDX file) and give back code that can be rendered to string by a React utility.
(If that sounds like a lot… I know. MDX gets into “long walk for a small win” territory when you’re trying to customize it.)
This did exactly what I needed, except it doesn’t support .astro
files.
I thought I could get clever and modify node_modules
to get it working, but I couldn’t get there. No matter what I tried, I was getting errors I couldn’t really understand or trace.
Third attempt: write an esbuild loader and just… hack the bejeebus out of it (success!)
As I was poking around in the mdx-bundler
source code, I realized that it was built using a custom esbuild loader to grab out the JSX files. I mostly understood what that meant, so I decided to try extending the esbuild config to see if I could add a custom loader for the components I load into my MDX, including .astro
files.
My loader started out like this:
const loadAstroAsJsx = { name: 'loadAstroAsJsx', setup(build: any) { build.onLoad({ filter: /(\.astro|\.tsx)$/ }, async (args: any) => { console.log('we did it!'); }); },};
Buckle up, because this is where things get… gross.
So, I could have tried to figure out how to parse Astro files and stringify their outputs. But I was several hours into this project by the time I got here and I know better than to trust my motivation to solve a problem like this to last past a single coding session, so I went a different route.
I decided to fully hijack the imports and return different JSX components instead.
Write a custom esbuild loader to hijack imports in MDX files
This approach has a lot of downsides. The biggest is that now I have two separate implementations of my MDX components. If I add a new one and forget to check my RSS feed, I’ll break my site. Not great.
But here’s the thing: my options were A) do something gross to get it working, or B) not have functional RSS on my site, so I chose to embrace my inner codevillain and let it rip.
I figured out that the args
includes args.path
, which I could use to get the name of the file.
import { basename } from 'node:path';
const loadAstroAsJsx = { name: 'loadAstroAsJsx', setup(build: any) { build.onLoad({ filter: /(\.astro|\.tsx)$/ }, async (args: any) => { console.log(basename(args.path)); //=> e.g. 'aside.astro' }); },};
Using that file name, I can determine which component is being requested. The loader also lets me return whatever I want, so I don’t have to use anything from the original file if I don’t want to.
To “replace” a component, I need to return an object with the file contents (as a string) and the loader to use for interpreting it:
import { basename } from 'node:path';
const loadAstroAsJsx = { name: 'loadAstroAsJsx', setup(build: any) { build.onLoad({ filter: /(\.astro|\.tsx)$/ }, async (args: any) => { console.log(basename(args.path)); //=> e.g. 'aside.astro'
return { contents: ` export default function Custom() { return <p>Replaced!</p>; } `, loader: 'jsx', }; }); },};
At this point, ANY component loaded into my MDX files will be swapped out for the paragraph with “Replaced!” inside.
Now that I know which component is being loaded and how to replace components in the loader, I can set up a switch and send back minimal JSX versions of my Astro components.
import { basename } from 'node:path';
const loadAstroAsJsx = { name: 'loadAstroAsJsx', setup(build: any) { build.onLoad({ filter: /(\.astro|\.tsx)$/ }, async (args: any) => { let contents;
switch (basename(args.path)) { case 'aside.astro': contents = `export default function Aside({ children }) { return <aside>{children}</aside>; }`; break;
case 'codepen.astro': contents = 'export default function CodePen({ slug, title }) { return <p>CodePen: <a href={`https://codepen.io/jlengstorf/pen/${slug}`}>{title}</a></p>; }'; break;
case 'figure.astro': contents = 'export default function Figure({ children }) { return <figure>{children}</figure>; }'; break;
case 'youtube.astro': contents = 'export default function YouTube({ children, id }) { return <p><a href={`https://youtu.be/${id}`}>Watch on YouTube</a></p>; }'; break;
case 'opt-in-form.astro': contents = `export function OptInForm() { return <p><a href="https://lwj.dev/newsletter">Subscribe to my newsletter for more like this!</a></p>; }`; break;
default: contents = `export default function Unknown() { return <></> }`; }
return { contents, loader: 'jsx', }; }); },};
Using the esbuild loader to render MDX with Astro components to string
Now that I had my loader defined, I needed to add it to the mdx-bundler
config and feed in each of my blog posts from Astro.
import type { CollectionEntry } from 'astro:content';import type { Plugin } from 'esbuild';import { basename, dirname, resolve } from 'node:path';import { renderToString } from 'react-dom/server';import { createElement } from 'react';import { getMDXComponent } from 'mdx-bundler/client';import { bundleMDX } from 'mdx-bundler';
const loadAstroAsJsx = { name: 'loadAstroAsJsx', setup(build: any) { build.onLoad({ filter: /(\.astro|\.tsx)$/ }, async (args: any) => { let contents;
switch (basename(args.path)) { case 'aside.astro': contents = `export default function Aside({ children }) { return <aside>{children}</aside>; }`; break;
case 'codepen.astro': contents = 'export default function CodePen({ slug, title }) { return <p>CodePen: <a href={`https://codepen.io/jlengstorf/pen/${slug}`}>{title}</a></p>; }'; break;
case 'figure.astro': contents = 'export default function Figure({ children }) { return <figure>{children}</figure>; }'; break;
case 'youtube.astro': contents = 'export default function YouTube({ children, id }) { return <p><a href={`https://youtu.be/${id}`}>Watch on YouTube</a></p>; }'; break;
case 'opt-in-form.astro': contents = `export function OptInForm() { return <p><a href="https://lwj.dev/newsletter">Subscribe to my newsletter for more like this!</a></p>; }`; break;
default: contents = `export default function Unknown() { return <></> }`; }
return { contents, loader: 'jsx', }; }); },};
export async function getHtmlFromContentCollectionEntry( post: CollectionEntry<'blog'>) { const result = await bundleMDX({ source: post.body, esbuildOptions(options) { return { ...options, plugins: [loadAstroAsJsx, ...(options.plugins as Plugin[])], }; }, cwd: resolve('./src/content/blog', dirname(post.id)), });
const Component = await getMDXComponent(result.code);
return renderToString(createElement(Component));}
How this works:
post.body
from Astro is the string contents of the MDX file, returned from Astro’s Content Collections — this gets passed as thesource
forbundleMDX()
- the custom loader I wrote gets put into the esbuild options as the first plugin in the list
- the
cwd
gets set to the directory of the current blog post so that the import paths all work (even though we’re replacing them we’d still get errors if the files didn’t exist) - the code value returned from
bundleMDX()
is then passed togetMDXComponent()
, which gives us a JSX component createElement()
turns that component into a React componentrenderToString()
turns that React component into an actual string of HTML
*gasps for breath*
This is A Lot™. But now I had a handy little utility function to can use in my RSS endpoint!
Returning full post content in an Astro RSS feed using MDX
With my utility file ready, I added it to src/pages/blog/rss.xml.ts
and got the rendered HTML for each of my MDX posts.
And as long as you don’t look inside the mdx-helpers
file, this is pretty tidy!
import rss from '@astrojs/rss';import type { AstroConfig } from 'astro';import { getCollection } from 'astro:content';import sanitizeHtml from 'sanitize-html';import { getHtmlFromContentCollectionEntry } from '../../util/mdx-helpers';
export async function GET(context: AstroConfig) { const blog = await getCollection('blog');
const mdxPosts = await Promise.all( blog.map(async (post) => { const html = await getHtmlFromContentCollectionEntry(post);
return { ...post, html, }; }) );
if (!context.site) { throw new Error('Setting a `site` in astro.config.mjs is required for RSS'); }
return rss({ title: 'Learn With Jason Blog RSS Feed', description: 'Articles and tutorials about web dev, career growth, and more.', site: context.site, items: mdxPosts .sort( (a, b) => new Date(b.data.pubDate).valueOf() - new Date(a.data.pubDate).valueOf() ) .map((post) => { const img = post.data.share?.image ?? false;
let html = '';
// if a sharing image was set, put it at the top of the post if (img) { html += `<p><img src="${img}" alt="${post.data.title}" /></p>`; }
html += post.html;
return { title: post.data.title, pubDate: post.data.pubDate, description: post.data.description, link: `/blog/${post.slug}`, content: sanitizeHtml(html, { // images are stripped by default allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), }), }; }), });}
Should anyone actually use this?
I mean, maybe? It will get the job done. It adds an extra thing to maintain and remember, which is bad, but it also allows sending full RSS content, which is good.
I’ll leave it as an exercise for you to decide if the trade-offs are worth it in your situation.
Good news: Astro will save us from my terrible code soon-ish
I showed this code to the Astro team and the pointed me to the Container API on their roadmap, which will make it possible for Astro to natively support “render MDX to string” in scenarios like RSS feeds. And astro-carton
implements the Container API proposal — I wasn’t able to get it running for my use case, but I also feel like my grasp of bundler-level stuff is tenuous at best, so it’s probably a skill issue.
I’m excited for what they come up with, because while I’m happy that I got this working, I have a lot of concerns about the lasting psychic damage I’m sure to suffer if my solution stays in production for too long. 😅