With Netlify’s acquisition of Gatsby and subsequent shutdown of Gatsby Cloud, it left many users racing to migrate their deployment pipeline to Netlify.

Netlify did do well to implement some of Gatsby Cloud’s features, but the overall experience feels half-baked. Many of the docs are incomplete, or haven’t been updated in a couple of years.

The site I was working on took over 30 minutes at cold boot to build on Gatsby Cloud. On Netlify, it ran out of memory. Images were taking up the most amount of time, to download from WordPress, then transform them.

I saw that Gatsby Cloud had an Image CDN service where images would be generated on the fly, rather than at build time. Bingo, that would sort out my build issues. Here’s how I actually got it to work.

The first attempt: Gatsby Image CDN

The original site was using gatsby-source-wordpress, gatsby-plugin-image and Sharp to download and process images during the build.

A typical GraphQL query looked something like this:

1featuredImage {
2 node {
3 localFile {
4 childImageSharp {
5 gatsbyImageData(
6 width: 570
7 height: 400
8 quality: 75
9 )
10 }
11 }
12 }
13}
Copied!

That works perfectly well for smaller websites. But when you’ve got thousands of product images coming from WordPress, it’s a lot of work to repeat during a cold build.

The first thing I tried was enabling the Gatsby Image CDN environment variable:

1[build.environment]
2 GATSBY_CLOUD_IMAGE_CDN = "true"
Copied!

I updated Gatsby and gatsby-source-wordpress to versions that supported the new image fields, and changed the GraphQL queries to use gatsbyImage directly.

1featuredImage {
2 node {
3 gatsbyImage(
4 width: 570
5 height: 400
6 quality: 75
7 )
8 }
9}
Copied!

The idea was to stop using localFile and childImageSharp entirely. Rather than downloading images and transforming them during the build, Gatsby could generate the image URLs and leave the actual processing until someone requested them.

Unfortunately, this wasn’t quite the end of it.

Making the images work on Netlify

The problem was that Gatsby was generating image URLs intended for Gatsby’s image service, rather than URLs that Netlify could necessarily serve.

I spent a while experimenting with Gatsby versions, the Netlify Gatsby plugin, redirects and the image processing integration.

As a temporary workaround, I ended up writing my own image-processing function using Sharp.

1const sharp = require("sharp")
2const fetch = require("node-fetch")
3 
4exports.handler = async (event) => {
5 const path = event.path.replace(/^\/.+\//, '')
6 const hash = event.queryStringParameters.hash || ''
7 
8 // Fetch the original image
9 const response = await fetch(
10 `${process.env.DEPLOY_URL}/static/${hash}/${path}`
11 )
12 const buffer = await response.buffer()
13 
14 // Get the original image dimensions
15 const metadata = await sharp(buffer).metadata()
16 
17 let width = parseInt(event.queryStringParameters.w) || metadata.width
18 let height = parseInt(event.queryStringParameters.h) || metadata.height
19 const format = event.queryStringParameters.fm || 'jpeg'
20 const quality = parseInt(event.queryStringParameters.q) || 60
21 
22 // Transform the image
23 const processedImage = sharp(buffer)
24 .resize(width, height)
25 .toFormat(format, { quality })
26 
27 // Give up after 3 seconds
28 const timeout = new Promise((resolve) => {
29 setTimeout(() => resolve(buffer), 3000)
30 })
31 
32 const result = await Promise.race([
33 processedImage.toBuffer(),
34 timeout
35 ])
36 
37 return {
38 statusCode: 200,
39 headers: {
40 'Content-Type': `image/${format}`,
41 'Netlify-CDN-Cache-Control':
42 'public, max-age=31536000, must-revalidate',
43 },
44 body: result.toString('base64'),
45 isBase64Encoded: true
46 }
47}
48// This is a shortened historical excerpt for the article, not a drop-in replacement.
49// The original includes error handling, dimension limits and additional headers.
50// Its timeout fallback also returned the original image buffer,
51// which could differ from the declared response format.
Copied!

The function downloaded the original image from the Netlify deployment, extracted its dimensions, resized it according to the query parameters, and returned the processed image with CDN caching headers.

I even added a three-second timeout so an image transformation couldn’t run indefinitely.

It worked, but it felt ridiculous. I’d migrated away from Gatsby Cloud’s image processing, only to find myself recreating an image CDN using a serverless function.

Thankfully, Netlify eventually introduced its native Image CDN, which meant I could delete the function and let Netlify handle the transformations instead.

Using Netlify Image CDN directly

Instead of relying on Gatsby to generate and transform images, I switched to passing the original WordPress image URL directly to Netlify.

Netlify Image CDN exposes an endpoint at:

1/.netlify/images
Copied!

You supply the image URL and the transformations you want as query parameters.

For example:

1/.netlify/images?url=https%3A%2F%2Fexample.com%2Fimage.jpg&w=570&q=75
Copied!

This tells Netlify to fetch the original image, resize it to 570 pixels wide and serve an optimised version.

The important difference is that the image isn’t downloaded and processed as part of the Gatsby build.

Netlify handles the transformation when the image is requested and caches the result for subsequent visitors.

Allow remote images

By default, Netlify won’t fetch images from arbitrary external domains. You need to explicitly allow the source domains in netlify.toml.

For a WordPress website, add:

1[images]
2 remote_images = [
3 "https://wordpress.example.com/.*"
4 ]
Copied!

Replace the example domain with the domain hosting your WordPress media.

If you’ve got separate staging and production WordPress environments, you’ll need to allow both.

For this project, WordPress was hosted on Kinsta, and we eventually added the staging domains too after discovering that images weren’t working properly in previews.

Create a reusable image component

The next step was replacing the Gatsby image components with a small React component that generated the Netlify image URL.

The basic implementation is straightforward:

1import React from "react";
2 
3const transformImageUrl = ({
4 imageUrl,
5 width,
6 height,
7 quality = 75,
8 fit = "contain",
9}) => {
10 if (!imageUrl) return "";
11 
12 const params = new URLSearchParams({
13 url: imageUrl,
14 q: String(quality),
15 });
16 
17 if (width) params.set("w", String(width));
18 if (height) params.set("h", String(height));
19 
20 if (width && height) {
21 params.set("fit", fit);
22 }
23 
24 return `/.netlify/images?${params.toString()}`;
25};
26 
27export default function Image({
28 image,
29 alt,
30 width,
31 height,
32 quality = 75,
33}) {
34 return (
35 <img
36 src={transformImageUrl({
37 imageUrl: image,
38 width,
39 height,
40 quality,
41 })}
42 alt={alt}
43 width={width}
44 height={height}
45 loading="lazy"
46 decoding="async"
47 />
48 );
49}
Copied!

Now I could use an image directly from its WordPress URL:

1<Image
2 image={product.featuredImage.node.mediaItemUrl}
3 alt={product.title}
4 width={570}
5 height={400}
6/>
Copied!

No getImage(), no GatsbyImage, and no Sharp transformation during the build.

I kept this as a reusable component because there were images throughout the site: product cards, galleries, hero sections, footer logos and content blocks.

A single component made it much easier to keep the behaviour consistent.

The example above uses a relative URL. Our actual implementation also supports an absolute site URL, which is useful when generating images for SEO metadata or external feeds.

Update your GraphQL queries

The final part was making sure Gatsby wasn’t still downloading images unnecessarily.

We had a lot of queries using localFile, including cases where the image was only needed to display in a normal <img> element.

For those, I switched to the original WordPress media URL.

Instead of:

1featuredImage {
2 node {
3 localFile {
4 childImageSharp {
5 gatsbyImageData(width: 570)
6 }
7 }
8 }
9}
Copied!

We could use:

1featuredImage {
2 node {
3 mediaItemUrl
4 altText
5 }
6}
Copied!

This is an important part of the migration. Just changing the React component isn’t enough if Gatsby is still being instructed to download and process images elsewhere in the site.

You also need to check other uses of image data. We had images in the product gallery, content components, hero backgrounds and SEO metadata.

Some of these needed slightly different treatment. CSS background images still needed transformed URLs, while social sharing images needed absolute URLs rather than relative paths.

SVG files were another exception. These could simply be passed through without image transformation.

A few things that caught me out

The final solution was much simpler than the original implementation, but it still took a few rounds of fixes to get everything working correctly.

Staging and preview domains. Netlify needs to be allowed to fetch images from whichever WordPress environment supplied them. Forgetting a staging hostname can leave images working in production but broken in preview deployments.

Images in SEO metadata. Open Graph images and structured data need URLs that external services can actually access. Relative image paths aren’t suitable for those uses.

Image dimensions. Netlify’s transformation parameters expect valid numeric dimensions. We later had to round width and height values to prevent errors when calculated sizes contained decimals.

Responsive images. Replacing GatsbyImage with a plain <img> means losing Gatsby’s automatically generated responsive srcSet and sizes. We subsequently added our own responsive variants, including different widths and device pixel ratios.

Image formats. Netlify can negotiate efficient formats such as WebP and AVIF automatically, but there are also options to request a specific format. We later extended the component to support this.

And finally, make sure you’ve removed the old image queries that are no longer needed. Otherwise, it’s possible to have all your images displaying through Netlify while Gatsby continues doing unnecessary work behind the scenes.

What about the official Gatsby integration?

Since I originally implemented this, Netlify’s Gatsby integration has improved.

For Gatsby 5.13 and later, the supported approach is to enable:

1NETLIFY_IMAGE_CDN=true
Copied!

Along with a supported source plugin and the appropriate remote image configuration.

This allows Gatsby’s image components to use deferred image processing without manually rebuilding the image handling.

It’s worth trying that first if you’re starting a new migration.

However, the direct /.netlify/images approach remains useful when you want more control over the image URLs, or when you’ve got a mixture of Gatsby images, CSS backgrounds and media URLs coming directly from a CMS.

You don’t have to abandon the rest of Gatsby to use it.

Was it worth it?

Absolutely.

Before the migration, a cold build took over 30 minutes on Gatsby Cloud. On Netlify, the same build would run out of memory before completing.

After moving image processing to Netlify Image CDN, build times dropped to around 10 minutes cold and 2 minutes warm.

That’s at least a 67% reduction in cold build time, with subsequent builds completing considerably faster thanks to caching.

It wasn’t a one-line configuration change, despite how simple the idea sounded. There were several attempts at getting Gatsby’s own Image CDN integration working before I ended up using Netlify’s native service directly.

But the final solution was relatively straightforward: allow the WordPress image domains, generate Netlify Image CDN URLs, and stop asking Gatsby to download and transform images it didn’t need.

Sometimes the easiest way to fix a slow build is to stop making the build do the work in the first place.