2 March, 20236 minute read

How to use next/image with Remote MDX

As I've mentioned previously, I took a few shortcuts when I first built this website. While engineering excellence is important, it's oftentimes a lot more important to ship something in the first place. Learning to make the right call between shipping and polishing is an important milestone in your software engineering career.

One of the tradeoffs I made was to use the plain old <img> HTML element for images on my blog instead of using Next.js's Image component. I don't generally write image-heavy posts, so it seemed pretty reasonable at the time. As I've written more, however, I've started to mix in more and more images.

I mitigated the issue a little bit by writing a script that optimized my images before deploying, but this isn't a perfect performance fix and also meant my build times went up fairly substantially.

Image component difficulties

While the Image component is great, it can be pretty challenging to use. There were two big problems:

  1. The Image component used to render a <div> around the image, which complicated styling.
    • Fixed in Next.js 13, and in Next.js 12.2+ (if you import from next/future/image)!
  2. The Image component needs to know the height & width of the image upfront to work.

Point number 2 is the real stickler here. When you're building out a static page, you don't even need to think about providing dimensions thanks to Next.js's Webpack loaders and can just import and use image files directly. In a more dynamic situation where posts are stored inside Markdown files, it's a lot harder to get those dimensions plumbed in to Image.

Lee Rob of Vercel's solution was to run a script that parsed the Markdown files with rehype, looked for image nodes in the AST, and then swapped out those nodes for jsx nodes that render an Image directly.

It's a much better solution than manually writing Image elements into your Markdown, which I've seen recommended elsewhere. While it might be a nice and simple solution, I didn't like it for a few reasons:

  1. In order to see how the post will look like with the Image component, I need to run a script first. I can't write my post and preview it right away.
    • Similarly, if I ever want to edit my image after running the script and wind up modifying its dimensions, I need to rerun the script.
  2. If I ever want to change the displayed image I need to delete the Image component usage and start from scratch, instead of being able to just change the path.
  3. I use a few premade components for rendering my images with a bit of formatting (e.g. adding a nice box shadow), so I can't just use plain Markdown image syntax. In my posts, an image might look like <FeatureImage alt="Some image" src="…" />, and Lee's script unfortunately doesn't cover this use case.

With my solution I didn't want to take on the compromises of points #1 and #2, and problem #3 is a blocker for me using Image.

How does the Next.js Image component work?

Just why is it so easy and painless to consume image files by statically importing them?

If you inspect the type definitions of Next.js's Image component or hover over one of your static image imports, you'll see this type appear. This type definition is the backbone of Next.js's Webpack magic that makes images so easy to consume from static pages.

While "regular" Webpack image loaders will just give you a string you can use as a src prop when you import an image, the loader used by Next.js gives you a bunch of extra information–most notably, its width and height. The StaticImageData type is what you get when you statically import images in Next.js, and it looks like so:

Click to copy
interface StaticImageData {  src: string;  height: number;  width: number;  blurDataURL?: string;  blurWidth?: number;  blurHeight?: number;}

You can see that the StaticImageData type contains the width and height of the imported image, which is how the Image component is able to work correctly, even though to the naked eye you've only passed a src without any dimensions.

Because this type exists I can simply write a plugin that will turn image paths inside my Markdown files into something which looks like a StaticImageData object, and then pass that object directly to Next.js's Image component.

There's a little bit of preliminary work here: because I'm using custom components and not rendering <img> tags directly, I had to change all of my components to take string | StaticImageData as their src in order to support this approach.

When it comes time to render the actual image, handling the union boils down to something as simple as typeof src === 'string' ? <img … /> : <Image … />. This is particularly straightforward now with the new and improved Image component that doesn't come with extraneous markup–because an Image renders down to a single img tag, any CSS rules applied will work more or less the same for both branches of the ternary.

Building the remark plugin

Similar to Lee, I wound up writing a custom plugin. It works as part of my SSG process so I don't need to manually run the script out of band from my regular editing process. It works like this:

  1. Find jsx nodes which render an image component
    • "Image component" here refers to my custom blog components–like that FeatureImage component I referenced earlier
  2. Parse out the image source for those image components
  3. Determine the size of the image
  4. Replace the string src prop for a StaticImageData object

Steps 1 & 2 are reasonably easily accomplished via regex:

Click to copy
const FILE_NAME_CHAR = '[\\", \\.\\/-\\w]';// In my use case, some components can take either `src="foo.png"`// or `src={['foo.png', 'bar.png']}`const MULTI_IMAGE_SRC_RE = new RegExp(` src=\\{\\[(${FILE_NAME_CHAR}+)\\]\\}`);const SINGLE_IMAGE_SRC_RE = new RegExp(` src=\\"(${FILE_NAME_CHAR}+)\\"`); // Returns the `src` prop from the given jsx node valueconst extractSrcSet = (jsxValue: string): string[] => {  const singleMatch = SINGLE_IMAGE_SRC_RE.exec(jsxValue);  if (singleMatch) {    return [singleMatch[1]];  }   const multiMatch = MULTI_IMAGE_SRC_RE.exec(jsxValue);  if (multiMatch) {    const paths = multiMatch[1]      .split(',')      .map((it) => it.trim())      .map((it) => it.replaceAll('"', ''));    return paths;  }   throw new Error(`Failed to extract src set: ${jsxValue}`);}; // Given a jsx node like `'<strong>Hello</strong>'`, returns `strong`const getElementType = (jsxValue: string): string => {  const match = /^<(\w+) /.exec(jsxValue);  if (!match) {    return '';  }   return match[1] ?? '';};

And we can even reuse the two regexes to come up with a function for replacing the src prop after we've processed it:

Click to copy
const replaceSrcSet = (  jsxValue: string,  newSrcSet: Array<string | StaticImageData>,): string => {  const newSrcProp = ` src={${JSON.stringify(newSrcSet)}}`;  if (SINGLE_IMAGE_SRC_RE.test(jsxValue)) {    return jsxValue.replace(SINGLE_IMAGE_SRC_RE, newSrcProp);  } else if (MULTI_IMAGE_SRC_RE.test(jsxValue)) {    return jsxValue.replace(MULTI_IMAGE_SRC_RE, newSrcProp);  }   // Shouldn't reach here due to throw in `extractSrcSet`  return jsxValue;};

Finding image sources and turning them into StaticImageData is reasonably easy given the building blocks from earlier:

Click to copy
import type { Node } from 'unist';import type { Plugin } from 'unified';import path from 'path';import sizeOf from 'image-size';import { visit } from 'unist-util-visit';// ...const IMAGE_COMPONENTS = new Set([  'FeatureImage',  'FeatureImageSideBySide',]); export const remoteImagePlugin: Plugin = () => {  return (tree) => {    visit(tree, (node) => {      if (node.type !== 'jsx') return;      if (!IMAGE_COMPONENTS.has(getElementType(node.value))) return;       const srcSet = extractSrcSet(node.value);      const staticImageData: Array<StaticImageData | string> = [];      for (const src of srcSet) {        const stats = sizeOf(path.join(PUBLIC_DIR, src));        if (!stats.width || !stats.height) {          staticImageData.push(src);        } else {          staticImageData.push({            height: stats.height,            width: stats.width,            src,          });        }      }       node.value = replaceSrcSet(node.value, staticImageData);    });  };};

And that's it! Most of the time spent building this was consumed tinkering with the regex, and the rest is actually fairly straightforward once you understand how Image works under the hood. In retrospect, there are actually a few different libraries available which parse JSX strings into a more readily usable data structure and it would have been far more robust to use one of those rather than hacking together a regex–but at least the regex approach works for now.

The results of swapping

I didn't take detailed web vital measurements like I did for the Tailwind article, but I can at least share one hard metric: the build time for my website went from ~2m40s all the way down to ~1m50s after removing my image optimization script. That's a pretty good saving, and lets me push content and updates much more quickly. As I've mentioned before, green software engineering for performance reasons can pay off in more ways than one.

Otherwise, all I can say is that the Image component does exactly what it's supposed to and images seem to load a lot quicker than they used to. Because Image knows the dimensions (and therefore the aspect ratio) of the image before its loaded in, blog images also now add zero cumulative layout shift which is a nice user experience win.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com