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:
- 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
)!
- Fixed in Next.js 13, and in Next.js 12.2+ (if you import from
- 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:
- 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.
- 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. - 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
.
Image
component work?
How does the Next.js 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:
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.
remark
plugin
Building the 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:
- Find
jsx
nodes which render an image component- "Image component" here refers to my custom blog components–like that
FeatureImage
component I referenced earlier
- "Image component" here refers to my custom blog components–like that
- Parse out the image source for those image components
- Determine the size of the image
- Replace the string
src
prop for aStaticImageData
object
Steps 1 & 2 are reasonably easily accomplished via regex:
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:
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:
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.