Skip to content


Improving (MDX) authoring experience in Astro / Solutions

Astro built-ins and plugins are not sufficient to deliver all the features or details that I want to have. But that's no problem at all because Astro is very open for extension!

This post is the summary of my hands-on, head first, and all heart approach at piecing together the Astro integration that delivers my ideal MDX authoring experience.

By the end of 2022 I helped a friend build a simple website with Astro and I basically saw the light: 💡 Astro is simple, elegant, and smart. It performs, and scales. Opinionated but extensible. A growing community vibing behind it. All of this and funding. 💰

I am quite surprised that you are so interested in something that I suggested

Yeah man, thank you for the tip!

I was so excited about it that I decided to (re)build my website with Astro. And so confident, that I aimed at a pretty big list of requirements for an awesome authoring experience.

Kudos 💯 * 💯

🙌 The Astro community, the Astro Technology Company, the core team, the documentation team, all the teams, yeah! 🙌

🙌 The remarkable astro-m2dx project which shares the same ideas of what great markdown experience looks like. 🙌

🙌 Hundreds of remark and rehype plugins that I browsed and 🤯 had me exploding with ideas! The unified ecosystem is prospering!

🔨 Let's build it

⚠️ This is not meant to be a tutorial. I do not recommend that you add so many dependencies and custom code to your site. Unless you are an obsessive tinkerer, willing to pick up a fight with a mob of though tasks, you will regret it.

Using relative paths in images

For starters, I basically replicated what @astro/image does, but without the restrictive convention of using the public/ folder for images.

Yes! 🙈 I am also opinionated about this! I like to keep my images next to my content. I want to use relative paths for my images, to have VSCode auto-complete them, and to preview them immediately.

Screenshot of VSCode with two split panes. On the left I am editing this page and on the right the MDX preview

So, the very first step was to write a remark plugin that rewrites relative image paths to absolute.

const publicPath = getPublicPath(baseDir, mdxFilename, node.url = publicPath;

Now that we're here, let's also collect an array of images used in the page, and a bit more context...

const { frontmatter } =;
frontmatter.images = images;
frontmatter.imageBaseDir = baseDir;
frontmatter.mdxFilename = mdxFilename;

... and we can use this to pick the first image for use in og:image meta tags.

I will also want to use it to pick thumbnails for lists of articles, but that will have to come later. 😉

Component mapping

In order to have the markdown images - ![alt](url "title") - render more than just an <img/> tag, we have to modify the MDX code before it is compiled, so that the correct component is in scope.

For this, I abducted the great ideas of astro-m2dx and some source code: the customComponents() remark plugin injects the necessary import and export statements into the code.

Note that this requires installing and consuming code from a few dependencies:

The component mapping file itself is as simple as import plus export:

import Figure from '@components/mdx/Figure.astro';

export const components = {
  img: Figure,

In my case I just want to render the Figure component explained below. But this can be used to replace ul, li, p, h1..6, hr, any markup you wish.

If you do that you can achieve total encapsulation of components and isolation of styles. But I calling this a wrap for now. Literally - dad joke 😄 - I am wrapping all places where markdown is rendered with a Markup component that forces some styles down the tree.

Image caption with attributions

Just a simple trick done in the <Figure> component, before it delegates rendering to the <Image> component, which allows me to provide an attribution inside the image title separated by //.

![Photo...](./astrud.jpg 'Astrud Gilberto // Wikipedia https://...')

And the attribution part of the title can also have a link that will turn into a Visit site external link.

Screenshot of the Figure component rendering a nice caption under the image, as well as a separate smaller line providing attribution and link to visit the original site

Responsive images

Finally, the <Image> component is responsible for figuring out the props for the source and image tags.

This includes setting the loading and decoding attributes, but more importantly generating all the URLs for different file formats and resolutions to use in <img> and <source> tags.

In dev mode it renders images via an endpoint attached to the vite dev server. Smart: only the images requested by the browser get generated.


And during the static build it generates public paths like /posts/2023-01/media/waterfall-in-forest-700.avif and collects them - via a rather questionable globalThis hack (please Astro community provide some better pattern? 🥺).

After all the HTML is generated, it takes the collected staticImages[] and generates all the individual formats.

By the way, I am using different responsive breakpoints and sizes for different types of images.

  • Regular images: 700px | 1400px
  • Hero images: 700px | 1400px | 2800px
  • Open graph images: 1200

I defined all the image profiles in one place and then merge the profile into the images props as late as possible.

Markdown wraps images in paragraphs, let's fix that

Since we are rendering <figure> instead of directly <img>, the wrapping paragraphs result in invalid HTML - <p><figure>...</p> and that can cause extra empty paragraphs polluting the rendered documented.


Adding remark-unwrap-images to the integration list of remark plugins, just before the relativeImages plugin, did what the name says.

Auto imports

For the "auto imports" features I also based it off astro-m2dx code and I am importing a bunch more from the m2dx-utils package and, as usual, a few more tools from the Unified.js ecosystem.

With the autoImports() remark plugin we can inject the necessary import statement into the MDX context whenever it detects the components are being used in the document.

We can then define which components to make available using a simple auto imports file such as this one:

import Abstract from '@components/Abstract';

export const autoimports = {

Again, astro-m2dx lets you create "auto import files" wherever you want in your project and does all the work to scan the file system for them every time it builds a page.

I decided to go with single auto-imports file because YAGNI, cutting complexity, and improving performance.

Eliminating layout shift.

Layout shift happens when the browser loads a resource and realises it needs to push content down and/or the side to make space for it.

If we know the width/height of the image we can prevent this by using the padding-top + percentage technique to reserve the space.

To find out the aspect ratio and make it available where it's needed, I wrote a couple of functions to readImage() into an instance of sharp and to getImageFacts(). I took the opportunity to also find the dominant colour 🖍️ which I use as the background colour, visible while the image is loaded.

Extracting an article abstract from the content

Another simple remark plugin, autoAbstract(), detects the <Abstract> component in the page and allows me to render its content, as markdown, anywhere on the layout, or even in other pages, lists of posts, etc...

<Abstract>Describe! [Link](/) :cowboy: Emoji!</Abstract>

I can also use the extracted content to render <meta name="description"> and og:description tags, stripping out all the funny stuff 👽 of course.

Extracting hero images from the content

A very similar process, another remark plugin, autoHero() identifies an image wrapped in <Hero>.

  ![Photo of hero wearing a cape](./media/so-hero.jpg "This hero wears capes // Public domain")

This way we can author the hero image in the normal flow of content, but then render it in different places such as the BlogPost layout. I later intend to use it in lists of posts.

Not necessarily an authoring improvement, but the quick externalLinks() rehype plugin adds a little CSS to display an arrow 👈 decoration to help users know if they're leaving my site... Oh no! don't leave! Come back!

Also, all external links must carry the rel="noopener" attribute for security and performance reasons.

What's next?

Next weekend I plan to figure out the whole stay in S3 or move to Netlify conundrum.

Priority is to replace the old version with the new one.

Screenshot of the old version of this website - with lighter, smaller typography - next to the new one - stronger and bigger typography, more contrast, a new logo, emojis!

Then I'll follow up with my website's backlog:

  • Remove the .mdx extensions from the links with a remark plugin.
  • Setup the frontmatter defaults per type of content.
  • The Only title should be the document's # title itself.

And many other content related features, such as media galleries, links, reading time, and - god forbid! - comments! 😇

Go back to top of the page