Faster Image Performance in Astro

Jason LengstorfJason Lengstorf

Astro is gaining momentum as an excellent framework for building high performance, modern websites. And while it has excellent image handling built in, this tutorial will show you 4 reasons why framework-specific image handling might let you down on professional sites — whether it's Astro, Next.js, Nuxt, or any of the options out there.

You'll learn how Cloudinary and Unpic can be added to any Astro site in minutes, and how they not only solve these 4 challenges, but add an additional 3 bonus features that will take your site image management to the next level.

So grab your favorite code editor and come along with me, your friend Jason, while we build pro Astro sites.

Get set up for development

Before we can get started, you'll need an Astro project. You can set a new Astro site up from scratch (npm create astro@latest) if you'd like, or if you want to follow along with this tutorial step-by-step you can fork the starter repo:

Terminal window
# fork the repo using the GitHub CLI (https://cli.github.com)
gh repo fork learnwithjason/cloudinary-site-images --clone=false
# clone the start branch of your forked repo
gh clone cloudinary-site-images --branch start
# move into the directory
cd cloudinary-site-images/
# install dependencies
npm i

This is a basic Astro site with pages and TODO comments in place to guide the project.

The built-in solution is great, but limited

Managing images in Astro can be done with no dependencies. Their built-in Image and Picture components are great.

Let's look at an example of optimizing a local image using Astro's built-in Image component. We'll be using this image by Karsten Winegeart for our examples.

Edit src/pages/01-basic.astro and put the following inside:

src/pages/01-basic.astro
---
import { Image } from 'astro:assets';
import Layout from '../layouts/default.astro';
import localImg from '../images/karsten-winegeart-F1PDaeAyr1A-unsplash.jpg';
---
<Layout>
<Image
src={localImg}
alt="A French bulldog in an orange hoodie against a blue background."
width={1200}
height={800}
widths={[300, 550, 1100, 2200]}
class="banner"
/>
</Layout>

This generates HTML similar to the following:

<img
src="/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash.D06bNIbJ_RkJ9X.webp"
srcset="/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash.D06bNIbJ_ZiRQRw.webp 300w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash.D06bNIbJ_1WDQ6x.webp 550w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash.D06bNIbJ_Z2ua6il.webp 1100w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash.D06bNIbJ_RkJ9X.webp 2200w"
alt="A French bulldog in an orange hoodie against a blue background."
class="banner"
width="1200"
height="800"
loading="lazy"
decoding="async"
/>

This is responsive, performant image code! However, when we start thinking about this across deploys and across web properties, there are a few things about this approach that might not be desirable:

  1. The image is processed and delivered as a local URL, so its cache will be lost on every deploy, even if the image doesn't change. This can lead to incremental bandwidth usage and slower page loads for users.
  2. If you have multiple web properties that use this image, each one will have a different cache, resulting in unnecessary bandwidth and download times.
  3. The developer needs to manually add the widths attribute on each image to create a srcset. This is a small additional step that must be taught to every teammate and checked for every image.
  4. There's no support for placeholder images. This is a nice-to-have, but without it there are blank spaces in the layout while images load.

In many cases, none of these present a significant issue. With that being said, we can eliminate all the above challenges with very little effort — so in my mind, it's worth the extra step.

Use Astro + Cloudinary + Unpic for better image caching, performance, and developer experience

To enhance image management on an Astro site, we're going to use Cloudinary and Unpic (created by Astro team member Matt Kane). This combination of tools eliminates the challenges of the built-in solution and provides additional features:

  • Using the same media CDN/UI components as other web properties in the company
  • Automatic srcset for without devs needing to know about or add extra attributes
  • Automatic placeholders while images load
  • Watermarking, overlays, and other image transformations
  • Handling user uploads of images
  • Advanced features, such as object-aware cropping, automatic alt text, and auto-moderation for user-uploaded images

I'll cover how to use these extra features in another tutorial. For now, let's look at how to set up the basics of Cloudinary + Unpic in an Astro project.

Step 1: Add your images to Cloudinary

If you don't already have a Cloudinary account, set up a new account on the free tier. Upload an image (Karsten Winegeart's photo will work again here) to your Cloudinary console and copy the public ID.

NOTE: Depending on when you created your Cloudinary account, public IDs may or may not include the folder or folders the image is stored in. If you're not sure, see the Cloudinary guide on finding the public ID.

Add your credentials as environment variables

For this tutorial, all we need is our Cloudinary cloud name. This is visible on your Cloudinary dashboard in the top left, as well as on the API keys page in your settings.

.env
CLOUDINARY_CLOUD_NAME="<your_cloud_name>"

Since the image URLs will be generated server-side (either at build time or at request time, depending on which output mode your Astro site is using, we don't need to make this available to the client.

Step 2: Install the required dependencies and update the config

Next, install the required dependencies in your Astro site:

Terminal window
npm i cloudinary @unpic/astro

Step 3: Update the image to use Cloudinary

Cloudinary's SDK provides helpers to generate URLs for our images. And while we can manually build URLs for our images, that can get pretty challenging as more advanced transformations come into play.

NOTE: We'll get into more advanced transformations in future tutorials. If you want to learn more, check out the Cloudinary image transformation docs.

Make the following updates to src/pages/01-basic.astro:

src/pages/01-basic.astro
---
import { v2 as cloudinary } from 'cloudinary';
import { Image } from 'astro:assets';
import Layout from '../layouts/default.astro';
import localImg from '../images/karsten-winegeart-F1PDaeAyr1A-unsplash.jpg';
cloudinary.config({
cloud_name: import.meta.env.CLOUDINARY_CLOUD_NAME,
});
const cdnImg = cloudinary.url(
'cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash',
{ transformation: { quality: 'auto', format: 'auto' } },
);
---
<Layout>
<Image
src={localImg}
src={cdnImg}
alt="A French bulldog in an orange hoodie against a blue background."
width={1200}
height={800}
widths={[300, 550, 1100, 2200]}
class="banner"
/>
</Layout>

In the call to cloudinary.url(), we include an object with a transformation property. While it's optional, one of the most powerful features of Cloudinary is that we can set quality and format to auto to automatically reduce the file size by both lowering the image quality to a level that isn't detectable by the human eye and by delivering in modern formats like WebP, AVIF, and others when the browser is capable of displaying them.

This is a huge win. Our original image weighs in at a whopping 2 MB. After adding automatic quality and format transformations, Cloudinary ends up delivering an asset that is only 119 kB before we get into responsive image sizes. Once those come into play, users on the smallest viewports will only have to download 13.1 kB — that's 99.3% smaller than the image we downloaded from Unsplash with barely any added code.

Now we get the following HTML output:

<img
src="/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash_2vWqI9.webp"
srcset="/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash_Z12sub8.webp 300w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash_1e4dMV.webp 550w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash_gI9kw.webp 1100w,
/_astro/karsten-winegeart-F1PDaeAyr1A-unsplash_Z1GuIPv.webp 2200w"
alt="A French bulldog in an orange hoodie against a blue background."
class="banner"
width="1200"
height="800"
loading="lazy"
decoding="async"
/>

...wait. Where did our image CDN go?

It turns out, Astro's Image component will process external images, but it does so by pulling them in at build time and turning them into processed, local assets.

This is why, if we want to fully optimize our media delivery, we also need to include Unpic.

Step 4: Update to Unpic

First, update the Astro config to use Unpic in astro.config.mjs:

astro.config.mjs
import { imageService } from '@unpic/astro/service';
import { defineConfig } from 'astro/config';
import db from '@astrojs/db';
import netlify from '@astrojs/netlify';
// https://astro.build/config
export default defineConfig({
output: 'hybrid',
image: {
service: imageService(),
},
integrations: [db()],
adapter: netlify(),
});

Once this is saved, update the import in src/pages/01-basic.astro to get Image from Unpic instead:

src/pages/01-basic.astro
---
import { v2 as cloudinary } from 'cloudinary';
import { Image } from 'astro:assets';
import { Image } from '@unpic/astro';
import Layout from '../layouts/default.astro';
const cdnImg = cloudinary.url(
'cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash.jpg',
{ transformation: { quality: 'auto', format: 'auto' } },
);
---
<Layout>
<Image
src={cdnImg}
alt="A French bulldog in an orange hoodie against a blue background."
width={1200}
height={800}
widths={[300, 550, 1100, 2200]}
class="banner"
placeholder="blurhash"
/>
</Layout>

Unpic will automatically generate the srcset, so we no longer need the widths attribute. And to show a placeholder on slower connections while images download, the placeholder attribute is set to blurhash, which is built into Unpic.

After building the site, the HTML for this image now looks like the following:

<img
alt="A French bulldog in an orange hoodie against a blue background."
class="banner"
loading="lazy"
decoding="async"
sizes="(min-width: 1200px) 1200px, 100vw"
style="object-fit:cover;background-image:url(data:image/bmp;base64,Qk32BAAAAAAAADYAAAAoAAAACAAAAAgAAAABABgAAAAAAMAAAAATCwAAEwsAAAAAAAAAAAAAtoIAsoQAp4U6mYFRjnVNiGcyiV8AjGAAvIwAt40Jq49Wm4puj39pi3FQjmook2sAw5YAvpcpsJlqn5SDlIl+kn1jlnY/nHYiyZ0AxJ8ytp9xppuJnJKEm4Zqn39Go34szKAAyKEuvKFor55/qJZ6p4xhp4Q+p4AizaAAyaAbwKBSuJ1ms5dhsY5KroUlqX0AzZ0Ayp0AxJ0wv5tAvJY8uY8jsoQAqXoAzJwAypwAxZsTwZolv5Yfu44As4MAqXgA);background-size:cover;background-repeat:no-repeat;max-width:1200px;max-height:800px;aspect-ratio:1.5;width:100%"
srcset="https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_640,h_427,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 640w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_750,h_500,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 750w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_828,h_552,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 828w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_960,h_640,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 960w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1080,h_720,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 1080w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1200,h_800,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 1200w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1280,h_853,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 1280w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1668,h_1112,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 1668w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1920,h_1280,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 1920w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_2048,h_1365,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 2048w,
https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_2400,h_1600,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash 2400w"
src="https://res.cloudinary.com/jlengstorf/image/upload/q_auto,w_1200,h_800,c_lfill,f_auto/v1/cloudinary-images/karsten-winegeart-F1PDaeAyr1A-unsplash"
/>

With minimal changes to our code, we now have modern, responsive, properly-sized-for-all-browsers, showing-placeholders-on-slow-connections images — and because we're using Cloudinary + Unpic, this will work in all of your web properties, with support for many frameworks: Angular, Astro, Lit, Preact, Qwik, React, Solid, Svelte, Vue, or WebC, and the Cloudinary CDN means images share a cache cross-property.

Recap: why Cloudinary + Unpic + Astro rules

At this point we've barely dipped our toes into the capabilities of Cloudinary, but already we've got 4 big wins — all for a few minutes' worth of work:

  1. Our images are now served from the Cloudinary CDN, so the cache for unchanged images will survive between deploys.
  2. Our images can now be cached across multiple web properties by using the same URLs for delivery.
  3. Our images are automatically optimized for file size and file type, giving us up to a 99% savings on our image sizes with no extra effort on our part.
  4. Our images now have placeholders when a slow network means our images need more time to download.

In future tutorials we'll go further with Cloudinary and learn how we can:

  • Add image overlays, watermarks, and other transformations to our images
  • Handle user generated content by allowing images to be uploaded
  • Automatically tag images and add image descriptions using AI tooling
  • Deal with moderation for user-generated content, both using human moderators and AI tooling
  • Leverage AI tooling to add smart cropping to images for better thumbnails

Resources and further reading

Get the latest Astro news, tutorials, and updates delivered to your inbox.

I respect your privacy. Unsubscribe at any time.