Next.js & Storyblok image optimization. Part 1

Check how to optimize images in Next.js with Storyblok to improve site performance, reduce load times, and avoid CLS. Explore the Next.js Image component, responsive loading, and real-world optimization techniques.

Next.js & Storyblok image optimization. Part 1

This is the first part of the article. The second one can be found here

Importance of Image Optimization in Web Development

Image optimization is crucial in web development. It impacts user experience and SEO ranking:

  • large, unoptimized images can significantly slow down webpage loading;
  • slow pages can negatively affect user experience;
  • unoptimized images consume more traffic, this affects you and your users: large images - more CDN traffic - higher costs for you, mobile devices use an expensive mobile network more traffic - higher costs for users;
  • faster websites tend to rank higher in search results.

The HTML simplest <img/> tag is sufficient for simple use-cases, but not for a large amount of images or big size images.

In this article, we will touch on the topic of image optimization with Nextjs and Storyblok. The main approach here is to control the size of the loaded images and delay loading an image until it is actually needed.

Example of Image Optimization Process Using Next.js Image Component

The simplest way to optimize images in Nextjs is to use built in Image from next/image. To enable this we need to add domain in next.config.js:

const nextConfig = {
  images: {
    domains: ["a.storyblok.com"],
  },
};

In our example it’s enough, but you can also limit yourself to a certain path:

module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'a.storyblok.com',
        port: '',
        pathname: '/f/spaceId/**',
      },
    ],
  },
}

Let’s put an image on a page:

import Image from "next/image";

export default function Page() {
  const width = 400;
  const aspectRatio = 5266 / 3403;
  const height = width / aspectRatio;

  return (
    <div>
      <Image
        src="https://a.storyblok.com/f/spaceId/5266x3403/99cbc784c3/pexels-eberhard-grossgasteiger-443446.jpg"
        alt="landscape"
        width={width}
        height={height}
      />
    </div>
  );
}

This image was uploaded to Storyblok by a content manager. It has JPG format and 4.57 MB size.

Image Optimization Results

We can inspect the image in a browser and see something like this:

<img alt="landscape" loading="lazy" width="400" height="258.4884162552222" decoding="async" data-nimg="1"
    style="color:transparent"
    srcset="/_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=640&amp;q=75 1x, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=828&amp;q=75 2x"
    src="/_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=828&amp;q=75">

NextJS added several attributes for optimization:

  • loading: "lazy" – load the image only when needed (when appears in the viewport). This is enabled by default.
  • decoding: "async" – let other content to be displayed and asynchronously decode the image
  • srcset="imageurl640 1x, imageurl828 2x" – this one provides different URLs for devices with different pixel densities, e.g. Retina displays have a higher pixel density and require higher quality images
  • placeholder="blur" – the blur effect, which is useful for lazy loading images smoothly
  • unoptimized: true – for bypassing Next.js optimization (useful in cases where external CDNs are optimized enough)

Note: By default we use loading: "lazy" but if the image is “Largest Contentful Paint” (LCP), we must pass the priority: true attribute to specify explicitly that the image should be loaded with the highest priority. With this attribute the browser will preload the image

The original image size was 4.57 MB, JPG format. but after optimization it became 116kB and WebP. This is the result of the image optimisation process that NextJS performs dynamically at runtime when requested. It downloads the images, resizes them and places them in the dist folder. So the app will consume them from the same place when the app is hosted but not from the CMS.

If you use an image with no dimensions it will result in CLS (Cumulative Layout Shift) when you have content below the image – in most cases you will have CLS. For example:

import Image from 'next/image'
export default function Page() {
  return (
    <main>
      <Image
        src="https://a.storyblok.com/f/spaceId/5266x3403/99cbc784c3/pexels-eberhard-grossgasteiger-443446.jpg"
        alt="landscape"
        // here should be dimensions to avoid CLS
      />
      <p>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
        veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
        commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
        velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
        occaecat cupidatat non proident, sunt in culpa qui officia deserunt
        mollit anim id est laborum.
      </p>
    </main>
  )
}

The text below the image will be shifted after the image has fully loaded

There are two options to solve this. We can display the image with or without a wrapper. In the example above the image needs a height and a width. Another way is to reserve a space and fill it with an image. Let’s add three images the following way:

import Image from "next/image";
export default function Page() {
const src = "https://a.storyblok.com/f/spaceId/5266x3403/99cbc784c3/pexels-eberhard-grossgasteiger-443446.jpg"

  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        gap: "48px",
        flexDirection: "column",
      }}
    >
      <div style={{ position: "relative", width: "100%", height: "200px" }}>
        {/* the image will be distorted, the proportions are not respected */}
        <Image
          src={src}
          alt="landscape"
          fill
        />
      </div>
      <div style={{ position: "relative", width: "100%", height: "200px" }}>
        {/* the image will be cropped to show part of the original image (center part in this case) to fit in the available space */}
        <Image
          src={src}
          alt="landscape"
          fill
          objectFit="cover"
          // objectPosition="top" or objectPosition="bottom" to show different part of the image
        />
      </div>
      <div style={{ position: "relative", width: "100%", height: "200px" }}>
        {/* Best case? fit the image into the available space, taking into account the aspect ratio */}
        <Image
          src={src}
          alt="landscape"
          fill
          objectFit="contain"
        />
      </div>
    </div>
  );
}

In a browser devtools we will see the first image like this:

<img alt="landscape" loading="lazy" decoding="async" data-nimg="fill"
    style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:contain;color:transparent"
    sizes="100vw"
    srcset="/_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Fs%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=3840&amp;q=75 3840w"
    src="/_next/image?url=https%3A%2F%2Fa.storyblok.com%2Ff%2F185240%2F5266x3403%2F99cbc784c3%2Fpexels-eberhard-grossgasteiger-443446.jpg&amp;w=3840&amp;q=75">

We can notice that in srcset attribute each size has individual url and a new attribute added sizes="100vw" - this one provide a rule how to pick an URL for an image from srcset based on size

Here are some key points regarding sizes attribute:

  • the sizes property specifies media conditions and image sizes for responsive image loading;
  • it works in tandem with the srcset property to help the browser choose the best image for the device;
  • sizes is a string with comma-separated pairs of media conditions and image sizes (in width followed by w). For example, (max-width: 640px) 100vw, 50vw means that for screens up to 640px wide, the image will be 100% viewport width, and for wider screens, it’ll be 50% viewport width;
  • you can also use pixel values in sizes for specifying image sizes.

The picture below shows three different ways of rendering the same image:

  1. objectFit isn’t specified. Image stretched to reserved space. As we don’t know the actual size of the image, the image will be distorted mostly in all cases.

  2. objectFit="cover" We show a piece of the image in the available slot. It might work if we knew which part to focus on. And also the image may look cropped.

  3. objectFit="contain". We scale down the image to fit the available space considering the aspect ratio.

The last case seems acceptable, but in most cases we want the image to be displayed at full width and full height, respectively – it seems that we need to know the dimensions of the image.

In this article, we highlighted the importance of image optimization in web development influencing user experience and SEO rankings. We have looked at how to optimize images from Headless CMS with NextJS means.

In the second part of the article, we’ll take a look at the Storyblok image service and look at an approach to getting image sizes.

We’re a specialist Storyblok agency helping teams transition to modern content platforms with clean architecture, scalability, and editorial control. If you’re planning a replatform or building from scratch, reach out to us — or book an intro call with our team.

FAQ

Answers to common follow-up questions about using Next.js image optimization with Storyblok in real projects.

Next.js image optimization usually lowers hosting and CDN costs, but the effect depends on traffic patterns and how you configure caching.

Key points:

  • Smaller files per request: Converting a 4.57 MB JPG to a ~100 kB WebP means you pay for far less outbound traffic per view. On high-traffic pages this can be a big saving.
  • One-time processing, many reads: The first request for a given src + width + quality + format combination triggers processing. The result is stored on disk (or in your hosting provider’s cache). Later requests reuse that file, so CPU cost is front-loaded.
  • Cache headers matter: Make sure your hosting/CDN is allowed to cache /_next/image responses with a long max-age and stale-while-revalidate. Without this, you pay more for repeated processing and origin traffic.

For very high scale, teams sometimes move heavy image work to a dedicated image CDN (like Cloudinary or imgix) and set unoptimized: true in Next.js. That shifts processing cost away from your app servers and can be cheaper if images are shared across multiple apps or domains.

Use the built-in Next.js Image component as your default if:

  • Your images mostly come from a few domains (like Storyblok) and are used by a single Next.js app.
  • You want a simple setup with minimal extra services.
  • You are fine with your app infrastructure doing the resizing and format conversion.

For editors, the main change is that they can focus on picking good source images and let the frontend handle sizing.

Good practices for content teams:

  • Upload high-quality originals, but not absurdly large: 2000–4000 px on the long edge is usually enough. Extremely large files (e.g. 12k px) slow down uploads and first-time processing.
  • Use consistent aspect ratios per component: If a “hero” block is designed for 16:9, ask editors to stick to that ratio. It reduces cropping surprises when objectFit="cover" is used.
  • Agree on focal points: If you know the subject of the image (e.g. a person on the right), document that in editorial guidelines so developers can set objectPosition or use Storyblok’s focal point features in part 2 of the article.

From the editor’s point of view, they still pick an asset in Storyblok. The Next.js app decides how that asset is rendered at different breakpoints. This separation works well when designers, developers, and editors agree on a small set of image patterns (hero, card, thumbnail, etc.) and stick to them.

There are a few practical limits to keep in mind:

  • Runtime processing load: On the first request for a new image variant, your server or hosting platform has to download and process the file. If you deploy and then get a traffic spike to many new images at once, this can cause short CPU or memory pressure.
  • Format support: Next.js outputs modern formats like WebP and AVIF where supported, but you don’t get fine-grained control per image out of the box. If you need strict control over formats for legal or design reasons, you may need a custom loader or external service.
  • No automatic knowledge of source dimensions: As the article notes, you must either pass width/height or use fill with a wrapper that has a defined size. Getting dimensions from Storyblok requires extra work, which is covered in the second part of the article.
  • Tight coupling to Next.js: The next/image component is specific to Next.js. If you plan to share the same frontend components with other runtimes (like React Native or a static site generator), you may need an abstraction layer.

If any of these are blockers, a dedicated image CDN or a custom image microservice might be a better fit.

Done well, this setup can improve several Core Web Vitals and, in turn, SEO.