A fast Astro portfolio helps showcase your work without sacrificing page performance. In this tutorial, you’ll learn how to build a high-performance Astro site using Cloudinary’s automated image delivery to deliver responsive images that load quickly across devices.
- Live Demo: https://my-portfolio-navy-two-o6ync5ksvm.vercel.app/
- GitHub Repo: https://github.com/musebe/my-portfolio
Images are essential, but when left unoptimized, they can cause slow load times, tanking your Core Web Vitals, search rankings, and customer experience.
Typical bottlenecks include:
- Serving raw, high-res JPEGs where a thumbnail would suffice.
- Content “jumping” because width and height attributes are missing.
- Sending the same desktop-sized file to a smartphone.
With Cloudinary’s dynamic image transformations, you can automate tedious, manual editing tasks like resizing, compressing, or exporting multiple versions of the same file just by tweaking the image’s URL.
For this portfolio, you’ll focus on three core optimizations:
-
f_auto. Automatically serve the most efficient format (like AVIF or WebP) based on the user’s browser. -
q_auto. Compress the image to the smallest possible weight without visible loss in quality. -
Responsive sizing (
w_). Scale the image to the exact pixels required for the visitor’s screen.
Here’s how the image optimizations work:
Let’s take a look at a processed Cloudinary URL.
https://res.cloudinary.com/cloud-name/image/upload/f_auto,q_auto,w_800/image-id
Code language: JavaScript (javascript)
Everything following the /upload/ directory tells Cloudinary how to transform the file. This ensures your portfolio stays professional without impacting performance.
Start by creating a new Astro project.
npm create astro@latest my-portfolio
Code language: CSS (css)
Move into the project folder:
cd my-portfolio
Then install the project dependencies:
npm install
Astro gives you a clean setup for building fast static sites. For this portfolio, you’ll use Cloudinary through image URLs. You do not need an extra Cloudinary package for the basic image setup.
Next, create a .env file in the project root:
PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name
Replace your_cloud_name with your Cloudinary cloud name. The PUBLIC_ prefix makes the value available to Astro when it builds the site.
To keep your image logic DRY (Don’t Repeat Yourself), create a utility file to manage your image transformations. This ensures every image across your portfolio follows the same optimization rules.
Create src/lib/cloudinary.ts and add the following:
const CLOUD_NAME = import.meta.env.PUBLIC_CLOUDINARY_CLOUD_NAME;
const BASE_URL = `https://res.cloudinary.com/${CLOUD_NAME}/image/upload`;
type ImageOptions = {
width?: number;
height?: number;
crop?: "fill" | "fit" | "scale";
gravity?: "auto" | "center";
};
function buildTransformations(options: ImageOptions = {}) {
const transformations = ["f_auto", "q_auto"];
if (options.width) transformations.push(`w_${options.width}`);
if (options.height) transformations.push(`h_${options.height}`);
if (options.crop) transformations.push(`c_${options.crop}`);
if (options.gravity) transformations.push(`g_${options.gravity}`);
return transformations.join(",");
}
export function getImageUrl(publicId: string, options: ImageOptions = {}) {
const transformations = buildTransformations(options);
return `${BASE_URL}/${transformations}/${publicId}`;
}
Code language: JavaScript (javascript)
Here’s why this works:
-
CLOUD_NAMEis pulled from your.envfile, keeping your credentials secure and easy to update. - Every URL generated automatically includes
f_autoandq_auto, so you’ll never have to remember to optimize for format or quality manually. - Using a TypeScript
typeforImageOptionsgives you autocomplete in your editor, preventing typos in transformation names likefillorgravity.
You can now generate a perfectly cropped hero image with a simple function call:
const heroUrl = getImageUrl("portfolio/project-one", {
width: 800,
height: 500,
crop: "fill",
gravity: "auto",
});
Code language: JavaScript (javascript)
This returns an optimized Cloudinary URL for that image. The main benefit is control. You can change your image rules in one file, and the whole portfolio will follow the same pattern.
Now that the URL helper is ready, it’s time to create a reusable Astro component for your images.
Create this file:
src/components/CloudinaryImage.astro
Add this code:
---
import { getImageUrl } from "../lib/cloudinary";
type Props = {
publicId: string;
alt: string;
width: number;
height: number;
class?: string;
};
const { publicId, alt, width, height, class: className } = Astro.props;
const src = getImageUrl(publicId, {
width,
height,
crop: "fill",
gravity: "auto",
});
---
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
class={className}
/>
Code language: HTML, XML (xml)
This component simplifies reusing your images. Instead of writing a full Cloudinary URL each time, you just have to pass a publicId, alt, width, and height.
These attributes help with:
-
alt: Screen readers and SEO. -
widthandheight: Prevents layout shifts. -
loading="lazy": Delays images until needed. -
decoding="async": Faster rendering of the page.
Now you can use the component like this:
<CloudinaryImage
publicId="portfolio/project-one"
alt="Screenshot of my portfolio project"
width={800}
height={500}
/>
Code language: HTML, XML (xml)
This keeps every portfolio image cleaner, faster, and easier to manage.
The next step is making images work well on different screen sizes. A desktop screen may need a large image, while a mobile phone should get a smaller one.
That is where srcset helps. Instead of sending the same image size to every device, srcset gives the browser a list of image sizes to choose from.
You can update the helper with a getSrcSet function:
export function getSrcSet(
publicId: string,
widths: number[] = [400, 800, 1200, 1600],
options: Omit<ImageOptions, "width"> = {}
) {
return widths
.map((width) => {
const url = getImageUrl(publicId, { ...options, width });
return `${url} ${width}w`;
})
.join(", ");
}
Code language: JavaScript (javascript)
This creates several Cloudinary URLs for the same image. Each URL has a different width.
For example:
image-400w
image-800w
image-1200w
image-1600w
The browser checks the user’s screen and picks the best size, reducing wasted bandwidth on mobile devices and ensuring a faster portfolio.
Images shouldn’t push content around as they load on the screen (aka layout shift). It can make a page feel unstable, especially on mobile.
To avoid this, every image should include a clear width and height.
<CloudinaryImage
publicId="portfolio/project-one"
alt="Screenshot of a portfolio project"
width={800}
height={500}
/>
Code language: HTML, XML (xml)
These values help the browser reserve space before the image appears. That means your layout stays steady while Cloudinary loads the optimized image.
This also supports accessibility. The alt text gives screen readers a clear description of the image. The fixed image size helps users avoid sudden page movement while reading or scrolling.
For a portfolio, both are essential because your work should feel easy to view from the first load.
A portfolio homepage often starts with a hero section.This is usually the first image users see, so it needs to load fast and look good. For this project, we use the same Cloudinary image in two ways:
- A sharp image for the main visual
- A blurred version for the background
The blurred version can be created with a Cloudinary transformation:
const blurredBg = getImageUrl("portfolio/hero-image", {
width: 1440,
height: 760,
crop: "fill",
gravity: "auto",
blur: 2000,
});
Code language: JavaScript (javascript)
The sharp image can use the same public ID, without the blur:
<CloudinaryImage
publicId="portfolio/hero-image"
alt="Portfolio project preview"
width={880}
height={495}
loading="eager"
/>
Code language: HTML, XML (xml)
The idea is simple. Cloudinary creates two versions from one image. You don’t need to edit or export extra files.
Use loading="eager" only for the hero image because it appears first on the page. Other images should stay lazy-loaded.
Open Graph images control how your portfolio links look when shared on social platforms.
Instead of creating a separate social image by hand, you can generate one with Cloudinary.
Add a helper function like this:
export function getOgImageUrl(publicId: string) {
return `${BASE_URL}/c_fill,w_1200,h_630,g_auto/e_brightness:-15,f_auto,q_auto/${publicId}`;
}
Code language: JavaScript (javascript)
This creates a 1200x630 image, which is a common size for social previews.
Here’s what the transformation does:
-
c_fill,w_1200,h_630crops the image to the right size. -
g_autohelps keep the main subject in view. -
e_brightness:-15darkens the image slightly. -
f_auto,q_autokeeps the image optimized.
You can then use the generated URL in your Astro layout:
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
Now each portfolio page can have a clean social preview image from the same Cloudinary asset. No extra design file or manual export needed!
Open the live demo:
https://my-portfolio-navy-two-o6ync5ksvm.vercel.app/
Then check a few things:
- Do the images load quickly?
- Do they keep their layout space?
- Do they look clear on mobile?
- Do project images resize well on smaller screens?
- Do shared links show the right preview image?
You can also inspect an image URL in the browser. You should see Cloudinary transformations like:
f_auto,q_auto,w_800
That means the image is being optimized through Cloudinary before it reaches the user. This confirms the main setup is working. Astro renders the portfolio, and Cloudinary prepares the images for faster delivery.
Before you wrap up, here are the key rules from this build.
- Always use
f_autoandq_autotogether. - Store Cloudinary public IDs, not full URLs.
- Add
widthandheightto every image. - Write clear alt text for every image.
- Use
loading="eager"only for hero images. - Lazy-load images below the first screen.
- Use
srcsetso the browser can pick the right size. - Keep Cloudinary URL logic in one helper file.
- Reuse one Astro image component across the site.
This setup keeps the portfolio easier to maintain.
When you add more projects later, you won’t have to rethink image performance. You can pass a public ID into the component, and Cloudinary will handle the image format, quality, and size.
Image performance matters on portfolio sites. Your work may look great, but large images can slow the page down and hurt the users’ experience.
In this build, you used Astro and Cloudinary for a cleaner image setup. You created a Cloudinary URL helper, a reusable image component, responsive image sizes, fixed dimensions, and Open Graph images. The result is a portfolio that loads faster, keeps the layout stable, and serves better images for each screen.
If you’d like to try Cloudinary for yourself, sign up for a free account.
You can view the final project here:
- Live Demo: https://my-portfolio-navy-two-o6ync5ksvm.vercel.app/
- GitHub Repo: https://github.com/musebe/my-portfolio
Why should I use Cloudinary for my Astro portfolio images?
Using Cloudinary allows you to offload image processing and delivery, ensuring your site remains fast without the “image tax” of unoptimized assets. It automates technical tasks like format conversion, quality compression, and responsive resizing through simple URL transformations, which directly improves Core Web Vitals and user experience.How does the app work with Cloudinary DAM?
The app integrates directly with Cloudinary DAM as a custom action. Users can select assets from the Cloudinary Media Library, open them in the Animation Maker, apply transformations, reorder frames, adjust timing, and export the final animation or spritesheet without leaving the DAM environment.WWhat do `f_auto` and `q_auto` do in Cloudinary transformations?
These are automatic optimization flags. f_auto (fetch format) identifies the best image format for the visitor’s browser (like AVIF or WebP), while q_auto (quality) compresses the file to the smallest possible size without any visible loss in quality.How do I prevent Content Layout Shift (CLS) when using remote images in Astro?
Yes. Each animation frame can have its own Cloudinary transformations applied independently. Users can preview transformations directly in the interface before generating the final output.Can I generate social media (Open Graph) images automatically?
Yes. You can create a helper function that uses Cloudinary transformations, such as c_fill,w_1200,h_630, to dynamically generate perfectly sized social preview images from your existing portfolio assets. This eliminates the need for manual design and exports for each page.When should I use loading=”eager” versus loading=”lazy” for images?
You should use loading=”eager” only for “above the fold” content, such as your hero image, to ensure it appears immediately when the page loads. All other images should use loading=”lazy” to delay download until the user scrolls near them, saving bandwidth and improving initial load speed.