Optimize CMS Images in Next.js with getImageProps
When you render content from a CMS like WordPress, the images might arrive as bare <img src="..." alt="..."> tags - no srcset, no lazy loading, no WebP. Next.js cannot touch them automatically because they live inside a raw HTML string, not a React component tree. This post shows how to use Next.js's getImageProps to inject proper srcset, WebP conversion, lazy loading, and responsive sizing into every image before it ever reaches the browser.
Why CMS Images Break Next.js Optimization
Next.js image optimization is one of the best reasons to use the framework. Automatic WebP conversion, lazy loading, proper srcset generation - it handles all of it. But it only works when you use the <Image> component from next/image.
When you pull a blog post body from WordPress or any headless CMS, you get a raw HTML string. That string contains plain <img> tags with nothing but a src and an alt. Those images load at full resolution, they block the render, and they tank your Core Web Vitals score.
You have two options: parse every image into a React <Image> component at render time, or pre-process the HTML string on the server before it reaches the client. The server-side approach is cleaner - it separates concerns and keeps your render components simple.
What getImageProps Actually Does
getImageProps is a lower-level API exported from next/image. It exposes the same optimization logic that powers the <Image> component, but as a plain async function call that returns props instead of JSX.
You pass it a src, alt, sizes, and optionally width and height. It returns a props object with an optimized src (pointing to Next.js's image CDN), a full srcSet, and sensible defaults for loading and decoding. You can spread those props directly onto any <img> element.
This is exactly what you need. Instead of rendering components, you process a string: find the <img> tags, run getImageProps, and write the optimized attributes back into the HTML string.
The optimizeImages Utility: How It Works
The utility is built from four small functions that each do one thing. Here is what each part does before you drop in the implementation.
parseImgAttributes takes a raw <img> tag string and returns a plain key-value object of its attributes. It strips the opening tag syntax, then runs a regex over the remaining text to capture attribute names and values. Keys are lowercased so attrs.src is always reliable regardless of how the CMS serialized the markup.
buildImgTag does the reverse: it takes that key-value map and serializes it back into a valid <img /> string. It escapes & and " inside attribute values to keep the HTML valid.
optimizeImageTag is the core. It calls getImageProps with the parsed src and alt, then merges the returned props back into the original attribute map. If getImageProps throws for any reason - a domain not listed in next.config, a malformed URL - the catch block adds lazy loading and async decoding to the original tag and moves on.
The CONTENT_IMAGE_SIZES constant tells the browser the image will span the full viewport on mobile and cap at 768px on wider screens. This maps to a typical blog content column. Adjust it to match your actual layout breakpoints.
Finally, optimizeImages ties everything together. It finds all <img> tags in the HTML string with a regex, runs optimizeImageTag on each one in parallel via Promise.all, then replaces the original tags in order.
Going Further: Fetching Real Image Dimensions
You may have noticed the // width, height comments in the original code. getImageProps generates more accurate srcset entries when it knows the intrinsic dimensions of the source image. Without them, Next.js falls back to a generic set of candidate widths that may not align with your images at all.
Providing dimensions also prevents layout shift. When the browser knows the aspect ratio before the image loads, it can reserve the correct space in the document flow. This directly improves your Cumulative Layout Shift (CLS) score.
The probe-image-size package reads just enough bytes from an image URL to extract the dimensions - it does not download the full file. Install it first:
npm install probe-image-size
npm install -save-dev @types/probe-image-sizeThen add this function to your utility file:
Rendering the HTML Safely with html-react-parser
Once optimizeImages has processed your HTML string, you need to render it in React. The first option most developers reach for is dangerouslySetInnerHTML. The name is not just a warning - it describes exactly what it does.
Setting innerHTML directly bypasses React entirely. React cannot reconcile those DOM nodes, cannot attach event handlers to elements inside them, and cannot prevent cross-site scripting (XSS) if any CMS content is ever user-generated or compromised. You also lose hydration correctness - server and client may produce different DOMs, which causes subtle bugs.
The right tool is html-react-parser. It parses the HTML string into a React element tree, not raw DOM nodes. React owns the output, hydration works correctly, and you can intercept any element with the replace option to swap it for a custom component if needed.
| Approach | XSS Safe | React Reconciliation | Event Handlers | Hydration |
|---|---|---|---|---|
dangerouslySetInnerHTML | No | No | No | Unreliable |
html-react-parser | Yes | Yes | Yes | Correct |
The replace callback is also useful as a safety net. If any <img> tag slipped through the server-side optimization step, you can catch it here and render a Next.js <Image> component instead. It gives you a clean hook into the parse tree without writing your own HTML walker.
Putting It All Together
The full pipeline is straightforward. Fetch your post body from the CMS, pass the HTML string through optimizeImages on the server, then hand the result to your component and render it with html-react-parser. Your readers get properly sized, lazy-loaded images in WebP format - and you never touched the CMS or changed a single template.
The approach scales well too. Since optimizeImages runs at build time or in a Server Component, there is zero client-side cost. All the heavy lifting happens before the HTML reaches the browser.