Developer Demo

Media & DAM Assets

How to model image properties, query them from Graph, and render them with Next.js Image - including DAM rendition patterns and the two different shapes Graph returns depending on context.

Modelling image properties#

Image fields use type: "contentReference" with allowedTypes: ["_image"]. This restricts the CMS picker to image assets only. The same pattern applies to video and file references. SDK docs ↗

Single image field + image array
// Single image field on a content type.
// type: "contentReference" with allowedTypes: ["_image"] restricts the picker
// to image assets only.

export const ImageBlockType = contentType({
  key: "ImageBlock",
  baseType: "_component",
  properties: {
    image:   { type: "contentReference", allowedTypes: ["_image"] },
    altText: { type: "string", displayName: "Alt Text" },
    caption: { type: "string", displayName: "Caption" },
  },
});

// Array of images (e.g. a logo grid):
// Use type: "array" with items: { type: "content", allowedTypes: ["_image"] }
// Graph inline-expands array items automatically - no extra fetch needed.
logos: {
  type: "array",
  items: { type: "content", allowedTypes: ["_image"] },
}
Asset reference fields for each media type
// Asset reference fields for the different media types.
// allowedTypes restricts the CMS picker to the matching asset kind.

// Images - single reference or an array:
avatar: { type: "contentReference", allowedTypes: ["_image"] }
logos:  { type: "array", items: { type: "content", allowedTypes: ["_image"] } }

// Video and file references:
document: { type: "contentReference", allowedTypes: ["_file"] }
video:    { type: "contentReference", allowedTypes: ["_video"] }

Two Graph response shapes#

Graph returns image references in different shapes depending on how the content was fetched. Content queried through a composition (Visual Builder page, content area) nests the URL under _metadata.url.default. Content queried directly via a page query returns the URL at url.default without the _metadata wrapper. A small helper that checks both shapes keeps component code clean.

Composition context

Block rendered inside Visual Builder or fetched via getContentByPath.

image._metadata.url.default

Direct page query

Block queried with a custom graphqlFetch call that requests the reference field explicitly.

image.url.default
Both shapes + defensive resolveImageUrl helper
// Graph returns image references in two different shapes depending on context.

// Shape A - composition context (Visual Builder / content area array item):
// The image comes back nested under _metadata:
{
  _metadata: {
    url:         { default: "https://cms.optimizely.com/globalassets/hero.jpg" },
    displayName: "Hero background"
  }
}

// Shape B - page query context (contentReference field queried directly):
// The url object is at the top level of the reference:
{
  url: { default: "https://cms.optimizely.com/globalassets/hero.jpg" }
}

// A defensive helper handles both shapes in one place:
type ImageRef =
  | { url?: { default?: string | null } | null }
  | { _metadata?: { url?: { default?: string | null } | null } | null }
  | null;

function resolveImageUrl(ref: ImageRef | undefined): string | null {
  if (!ref) return null;
  if ("url" in ref) return ref.url?.default ?? null;
  return ref._metadata?.url?.default ?? null;
}

// Usage in the component:
const avatarUrl = resolveImageUrl(data.avatar);

damAssets() for responsive srcsets and DAM alt text#

The SDK ships a damAssets() helper that builds responsive srcset strings, reads alt text stored in the DAM (rather than as a separate CMS property), and provides TypeScript type guards for distinguishing image, video, and raw file references. It also appends the preview token to image URLs automatically when rendering in edit mode, so DAM images load correctly in the Visual Builder editor without extra code. SDK docs ↗

damAssets - getSrcset, getAlt, isDamImageAsset
// damAssets() - SDK helper for responsive srcsets and DAM-stored alt text.
// Use it when you want responsive images without manually building srcset strings,
// or when alt text is stored in the DAM rather than as a separate CMS property.

import { damAssets } from "@optimizely/cms-sdk";

export default function HeroBlock({ content }) {
  const { getSrcset, getAlt, isDamImageAsset } = damAssets(content);

  // getSrcset builds a srcset string from the asset's pre-generated Renditions:
  // "https://...hero.jpg/rendition1 100w, https://...hero.jpg/rendition2 500w, ..."
  // In edit mode it appends the preview token to each rendition URL automatically.
  const srcset = getSrcset(content.backgroundImage);

  // getAlt pulls alt text stored in the DAM, falling back to the provided string:
  const alt = getAlt(content.backgroundImage, "Hero image");

  // isDamImageAsset / isDamVideoAsset / isDamRawFileAsset are TypeScript type guards:
  if (!isDamImageAsset(content.backgroundImage)) return null;

  return (
    <img
      src={content.backgroundImage.item.Url}
      srcSet={srcset}
      sizes="100vw"
      alt={alt}
      className="w-full h-auto"
    />
  );
}

// Note: getSrcset returns a plain srcset string for <img>, not a Next.js <Image>.
// Use Next.js <Image> with a fixed src when you want automatic format conversion (WebP/AVIF).

Live example - getSrcset output rendered as a real image

Pandas

srcset: https://images1.cmp.optimizely.com/assets/Pandas.jpg/de3a279ef77811f0b938e24c027d659d 100w, https://images1.cmp.optimizely.com/assets/Pandas.jpg/e7e62fc6cef711f0b939162bee84ea3a 500w, https://images1.cmp.optimizely.com/assets/Pandas.jpg/c04b03acf77811f0b938e24c027d659d 700w, https://images1.cmp.optimizely.com/assets/Pandas.jpg/Zz04MGM1Yzk2YzViNDUxMWYwYjZjMjI2Y2Q3YjRiOGM3OA== 5456w

getSrcset() walks the Renditions array in ascending width order. The original URL is appended as the largest candidate so the browser always has a full-resolution fallback. On desktop, drag the bottom-right handle and check DevTools → Network → Img to see which entry is fetched at each width.

In edit mode getSrcset() automatically appends the CMS preview token to each rendition URL so DAM images load correctly in Visual Builder without extra code.

getAlt() returns AltText stored on the DAM asset, falling back to the string you provide - here Pandas.

Use damAssets when

  • You want responsive srcset strings without building them manually
  • Alt text is managed in the DAM rather than as a CMS property
  • You need to distinguish image vs video vs file references at runtime
  • You want preview token handling in edit mode without custom code

Use direct URL access when

  • You only need the URL for a Next.js <Image> (which handles srcset internally)
  • Alt text is a separate string property on the content type
  • The image is always the same type - no type guard needed
  • You want minimal SDK surface area in the component

DAM renditions and srcset#

DAM images in Graph expose pre-generated renditions via cmp_PublicImageAsset. The content picker selects the asset; rendition selection is handled in code. When your DAM stores size variants of the same image - a thumbnail, medium, and large version of the same composition - build a srcset string from the Renditions array and let the browser download the smallest one that fits the rendered width. Always pair srcSet with a sizes attribute - without it the browser assumes 100vw and downloads the largest file on every viewport. To skip building this string manually, use damAssets().getSrcset() above.

GraphQL fragment
// Graph fragment - cmp_PublicImageAsset fields are PascalCase.
// Each Renditions entry is a physically separate pre-generated file, not a resize parameter.
fragment RenditionImageFields on RenditionImageBlock {
  image {
    ... on cmp_PublicImageAsset {
      Renditions {
        Name
        Url
        Width
        Height
      }
      Url
    }
  }
}
Component - srcset from renditions
const RENDITION_WIDTHS: Record<string, number> = {
  thumbnail: 480,
  medium:    1280,
  large:     1920,
};

const srcset = (content.image?.Renditions ?? [])
  .filter((r) => RENDITION_WIDTHS[r.Name] !== undefined)
  .map((r) => `${r.Url} ${RENDITION_WIDTHS[r.Name]}w`)
  .join(", ");

<img
  src={content.image?.Url}
  srcSet={srcset}
  sizes="(max-width: 480px) 100vw, (max-width: 768px) 50vw, 700px"
  alt={alt}
/>

Live examples

The browser picks one rendition at load time and locks it in for that page load. To see a different rendition: resize the browser window to a new breakpoint, then hard reload. DevTools → Network → Img shows which URL was fetched.

Otters.jpg - srcset with original

3402 × 3402 original included as the largest candidate. Resize your browser viewport and hard reload - DevTools → Network → Img shows which URL was fetched.

Otters

src=original (3402px)  •  sizes="≤300px→100w, ≤600px→500w, ≤1100px→700w, 3402w"

Pandas.jpg - original + three renditions

5456 × 3632 original. Three pre-generated renditions from the same DAM instance.

Original

Original

5456 × 3632

100px crop

100px crop

100 × 67

500x500 WEBP

500x500 WEBP

500 × 500

700px Crop

700px Crop

700 × 466

Author-controlled rendition selection#

Consider a photography asset that has both a portrait crop (used in profile cards) and a landscape crop (used in hero banners). An author placing that asset into a MediaBlock needs to say which crop fits the context - srcset cannot help here because the choice is about composition, not screen size. Add a string enum property whose values match the rendition names in your DAM instance exactly, and the author picks both the image and the crop. If a component always uses the same crop, pick it by name in code instead - no CMS property needed.

Content type + GraphQL fragment
// Add a rendition enum alongside the image field.
// Enum values must match rendition names in your DAM instance exactly -
// these are the names shown in the DAM, not file names or URLs.
export const MediaBlockType = contentType({
  key: "MediaBlock",
  baseType: "_component",
  properties: {
    image: { type: "contentReference", allowedTypes: ["_image"] },
    rendition: {
      type: "string",
      enum: [
        { value: "portrait-crop",  displayName: "Portrait (cards, profile images)" },
        { value: "landscape-crop", displayName: "Landscape (heroes, banners)" },
      ],
    },
    altText: { type: "string" },
  },
});

// The GraphQL fragment is the same as for srcset - query Renditions regardless.
fragment MediaBlockFields on MediaBlock {
  image {
    ... on cmp_PublicImageAsset {
      Renditions { Name Url Width Height }
      Url
    }
  }
  rendition
  altText
}
Component - rendition lookup and fallback
// Walk the Renditions array to find the author-selected rendition by name.
// Fall back through base Url, then both Graph shapes for the image URL.
const renditions = content.image?.Renditions ?? [];
const matched = renditions.find((r) => r.Name === content.rendition);
const src =
  matched?.Url ??                            // rendition-specific file
  content.image?.Url ??                      // base DAM URL (original)
  content.image?._metadata?.url?.default ??  // composition-context shape
  content.image?.url?.default;               // direct page query shape

// Use matched dimensions for layout shift prevention when available:
<Image
  src={src}
  alt={content.altText ?? ""}
  width={matched?.Width ?? 1200}
  height={matched?.Height ?? 800}
  className="w-full h-auto"
/>

Allowlisting CMS domains in next.config#

Next.js <Image> blocks remote images from any domain not listed in remotePatterns. The error is a 400 at runtime, not a build-time failure - easy to miss locally if the images happen to load from cache. Three patterns are needed: one for CMS-uploaded assets (**.cms.optimizely.com), one for DAM assets served by CMP (**.cmp.optimizely.com - images are served from subdomains like images1, images2, images3), and one for Graph CDN delivery (cg.optimizely.com).

next.config.ts - required remotePatterns
// next.config.ts - allowlist Optimizely domains before using next/image.
// Missing entries cause a 400 error at runtime, not at build time.

images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "**.cms.optimizely.com",  // CMS-uploaded assets (globalassets, etc.)
    },
    {
      protocol: "https",
      hostname: "**.cmp.optimizely.com",  // DAM assets served from CMP (images1/2/3.cmp.optimizely.com)
    },
    {
      protocol: "https",
      hostname: "cg.optimizely.com",       // Graph CDN delivery
    },
  ],
},

// If editors can add external image URLs (e.g. a url property type), add those
// domains too - or use loader: "custom" to proxy all images through your own origin.

Image proxying in production#

For production sites, consider proxying DAM images through your own domain rather than loading them directly from Optimizely CDN subdomains. This keeps asset URLs stable if Optimizely's CDN hostnames change, lets you set your own Cache-Control headers, and avoids exposing third-party hostnames in your page HTML.

A minimal Next.js route handler fetches the image server-side and streams it back. Always validate the source hostname before fetching - without it the route becomes an open proxy that anyone can use to fetch arbitrary URLs through your origin.

Alternative - configure the Media hostname in the CMS UI

Rather than proxying at request time, you can tell the CMS to index media URLs under your own domain from the start. In the CMS go to Settings → Applications → Hostnames, find the Media entry, and overwrite it with your domain. Graph will then store and return asset URLs rooted at your hostname rather than the default Optimizely CDN subdomains - no proxy route or remotePatterns rewrites needed on the Next.js side. Use this approach when you want the URL change to be reflected in Graph itself, so all clients (not just your frontend) see your domain.

src/app/api/image-proxy/route.ts
// src/app/api/image-proxy/route.ts
// A simple Next.js route handler that fetches the image server-side and streams
// it back. Validate the source hostname before fetching to prevent open-proxy abuse.

const ALLOWED_HOSTS = [".cms.optimizely.com", ".cmp.optimizely.com"];

export async function GET(req: Request) {
  const src = new URL(req.url).searchParams.get("src") ?? "";
  let srcHostname = "";
  try { srcHostname = new URL(src).hostname; } catch {}
  if (!ALLOWED_HOSTS.some((h) => srcHostname.endsWith(h))) {
    return new Response("Forbidden", { status: 403 });
  }
  const upstream = await fetch(src);
  return new Response(upstream.body, {
    headers: {
      "Content-Type": upstream.headers.get("Content-Type") ?? "image/jpeg",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });
}

// Then use the proxy URL as the src:
// <Image src={`/api/image-proxy?src=${encodeURIComponent(damUrl)}`} ... />

Live example - image loaded through /api/image-proxy

Pandas - served via /api/image-proxy

/api/image-proxy?src=https%3A%2F%2Fimages1.cmp.optimizely.com%2Fassets%2FPandas.jpg%2Fc04b03acf77811f0b938e24c027d659d

The image URL in the page HTML is /api/image-proxy?src=... - your own origin, not images1.cmp.optimizely.com.

Open DevTools → Network → Img and reload to confirm the request goes to /api/image-proxy and returns with your Cache-Control: immutable header.

Requesting a URL from a non-allowed host (e.g. example.com) returns 403 Forbidden.

Next.js Image patterns#

Three patterns cover the main CMS image use cases. Use fill when the container controls the dimensions (hero backgrounds, logo grids). Use fixed width and height for thumbnails and avatars. Use natural dimensions for editorial images where the asset's own aspect ratio should flow into the layout. Always provide a sizes prop with fill so the browser downloads an appropriately sized image on mobile.

fill vs fixed size vs natural editorial image
// Three Next.js <Image> usage patterns for CMS images.

// 1. Fill - for images in a sized container (hero backgrounds, logo grids)
//    Parent must be position: relative and have explicit dimensions.
<div className="relative w-full h-64 overflow-hidden">
  <Image src={imageUrl} alt={altText} fill className="object-cover" />
</div>

// 2. Fixed size - for avatars and thumbnails where dimensions are known
<Image src={avatarUrl} alt={name} width={72} height={72} className="rounded-full object-cover" />

// 3. Natural editorial image - provide a maximum intrinsic size and let CSS flow the layout
<Image src={imageUrl} alt={altText} width={1200} height={675} className="w-full h-auto" />

// sizes prop - always provide it with fill to avoid downloading full-width images on mobile:
<Image
  src={logoUrl}
  alt={logoName}
  fill
  className="object-contain"
  sizes="(max-width: 768px) 96px, 128px"
/>

Key Things to Know#

  • Graph returns two different shapes for image references. Composition context gives _metadata.url.default; direct page queries give url.default. Write a defensive helper that checks both.
  • Allowlist all three Optimizely domains in next.config before using <Image>. **.cms.optimizely.com for CMS assets, **.cmp.optimizely.com for DAM assets from CMP, and cg.optimizely.com for Graph CDN. Missing any one causes a 400 at runtime - not caught at build time.
  • Always provide a sizes prop when using fill. Without it Next.js defaults to 100vw, causing the browser to download a full-width image even on mobile devices.
  • Always pair srcSet with a sizes attribute. Without sizes the browser assumes 100vw and downloads the largest srcset candidate on every viewport. Use damAssets().getSrcset() to build the srcset string from the asset's pre-generated DAM renditions automatically, or build it manually from the Renditions array when you need custom filtering.
  • Image arrays use type: "array" with items: type: "content". Graph inline-expands these automatically - all _metadata fields arrive with the page query, no extra fetch needed.
  • damAssets() handles preview token injection automatically. If you pass a DAM image ref through getSrcset() in edit mode, the helper appends the preview token so the image loads correctly in Visual Builder.
  • The content picker cannot select a DAM rendition - serve it from code. All rendition data is available in Graph under cmp_PublicImageAsset.Renditions. Default to building a srcset so the browser picks by viewport width. Expose a rendition dropdown to authors only when renditions represent different crops or formats rather than size variants of the same image.
Source files3 files
src/components/blocks/ImageBlock/index.tsx
import { contentType, displayTemplate, damAssets } from "@optimizely/cms-sdk";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";

export const ImageBlockType = contentType({
  key: "ImageBlock",
  displayName: "Image Block",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled", "elementEnabled"],
  properties: {
    // No indexingType: the SDK omits indexingType:"disabled" reference fields from
    // its generated fragment (createQuery), so the image would never be queried.
    image: { type: "contentReference", displayName: "Image", allowedTypes: ["_image"] },
    rendition: {
      type: "string",
      displayName: "Image Rendition",
      enum: [
        { value: "100px crop",   displayName: "Thumbnail (100px crop)" },
        { value: "500x500 WEBP", displayName: "Medium (500x500 WEBP)" },
        { value: "700px Crop",   displayName: "Large (700px crop)" },
      ],
    },
    altText: { type: "string", displayName: "Alt Text", isLocalized: true },
    caption: { type: "string", displayName: "Caption", isLocalized: true },
  },
});

export const ImageBlockRoundedTemplate = displayTemplate({
  key: "ImageBlockRoundedTemplate",
  isDefault: false,
  displayName: "Rounded corners",
  contentType: "ImageBlock",
  tag: "Rounded",
  settings: {
    aspectRatio: {
      editor: "select",
      displayName: "Aspect ratio",
      sortOrder: 0,
      choices: {
        auto: { displayName: "Auto",           sortOrder: 0 },
        r16x9: { displayName: "16:9 Widescreen", sortOrder: 1 },
        r4x3:  { displayName: "4:3 Standard",    sortOrder: 2 },
        r1x1:  { displayName: "1:1 Square",      sortOrder: 3 },
      },
    },
  },
});

interface ImageBlockData {
  // DAM assets expose Url/Renditions under image.item; the SDK's src()/getSrcset
  // read that. CMS globalassets expose their URL at image.url.default (page query)
  // or image._metadata.url.default (composition), handled by the fallbacks below.
  image?: {
    url?: { default?: string | null } | null;
    _metadata?: { url?: { default?: string | null } | null } | null;
  } | null;
  rendition?: string | null;
  altText?: string | null;
  caption?: string | null;
}

type ImageBlockProps = ImageBlockData & {
  content?: ImageBlockData;
  displaySettings?: Record<string, string | boolean>;
  displayTemplateKey?: string;
};

// Choice keys cannot contain "/" or ":", so map them to valid CSS aspect-ratio values
const ASPECT_RATIOS: Record<string, string> = {
  r16x9: "16 / 9",
  r4x3:  "4 / 3",
  r1x1:  "1 / 1",
};

export default function ImageBlock(props: ImageBlockProps) {
  const data = props.content ?? props;
  const ds = props.displaySettings;
  const { pa, src } = getPreviewUtils(data as any);
  const { getSrcset, getAlt } = damAssets(data as any);

  // src() resolves the DAM asset URL (image.item.Url) and appends the preview
  // token in edit mode; the fallbacks cover CMS globalassets (no DAM item).
  const imageUrl =
    src(data.image as any) ??
    data.image?.url?.default ??
    data.image?._metadata?.url?.default;

  if (!imageUrl) return null;

  const srcSet = getSrcset(data.image as any);
  const altText = getAlt(data.image as any, data.altText ?? "");

  const isRounded = props.displayTemplateKey === "ImageBlockRoundedTemplate";
  const aspectRatio = ASPECT_RATIOS[(ds?.aspectRatio as string) ?? "auto"];

  return (
    <figure data-component="ImageBlock" className="max-w-7xl mx-auto px-8 py-8">
      <div
        className={`relative overflow-hidden ${isRounded ? "rounded-2xl" : ""}`}
        style={aspectRatio ? { aspectRatio } : undefined}
      >
        {/* eslint-disable-next-line @next/next/no-img-element -- DAM URLs carry a preview token; next/image would re-optimise and strip it */}
        <img
          src={imageUrl}
          srcSet={srcSet}
          sizes="(max-width: 1280px) 100vw, 1280px"
          alt={altText}
          className={`${aspectRatio ? "absolute inset-0 h-full w-full object-cover" : "w-full h-auto"} ${isRounded ? "rounded-2xl" : ""}`}
        />
      </div>
      {data.caption && (
        <figcaption
          {...pa("caption")}
          className="text-sm mt-4 text-center text-on-surface-variant"
        >
          {data.caption}
        </figcaption>
      )}
    </figure>
  );
}
src/components/blocks/LogoGridBlock/index.tsx
import Image from "next/image";
import { contentType, displayTemplate } from "@optimizely/cms-sdk";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { TEXT_ALIGN, TEXT_ALIGN_CLASSES } from "../_shared/displayTemplateSettings";

export const LogoGridBlockType = contentType({
  key: "LogoGridBlock",
  displayName: "Logo / Partner Grid",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],
  properties: {
    heading:    { type: "string", displayName: "Heading",    isLocalized: true },
    subheading: { type: "string", displayName: "Subheading", isLocalized: true },
    logos: {
      type: "array",
      displayName: "Partner Logos",
      items: { type: "content", allowedTypes: ["_image"] },
      indexingType: "disabled",
    },
  },
});

export const LogoGridColorTemplate = displayTemplate({
  key: "LogoGridColorTemplate",
  isDefault: false,
  displayName: "Full color logos",
  contentType: "LogoGridBlock",
  tag: "Color",
  settings: {
    size: {
      editor: "select" as const,
      displayName: "Logo size",
      sortOrder: 0,
      choices: {
        sm:      { displayName: "Small",    sortOrder: 0 },
        default: { displayName: "Standard", sortOrder: 1 },
        lg:      { displayName: "Large",    sortOrder: 2 },
      },
    },
    showNames: {
      editor: "checkbox" as const,
      displayName: "Show partner names",
      sortOrder: 1,
      choices: {},
    },
    ...TEXT_ALIGN,
  },
});

interface LogoItem {
  _metadata?: {
    url?: { default?: string | null } | null;
    displayName?: string | null;
  } | null;
}

interface LogoGridData {
  heading?:    string | null;
  subheading?: string | null;
  logos?:      Array<LogoItem | null> | null;
}

type LogoGridBlockProps = LogoGridData & {
  content?: LogoGridData;
  displaySettings?: Record<string, string | boolean>;
  displayTemplateKey?: string;
};

const PLACEHOLDER_COUNT = 6;

const LOGO_SIZES: Record<string, { wrapper: string; imgSizes: string }> = {
  sm:      { wrapper: "w-24 h-12", imgSizes: "96px" },
  default: { wrapper: "w-32 h-16", imgSizes: "128px" },
  lg:      { wrapper: "w-40 h-20", imgSizes: "160px" },
};

const FLEX_ALIGN: Record<string, string> = {
  left:   "justify-start",
  center: "justify-center",
  right:  "justify-end",
};

export default function LogoGridBlock(props: LogoGridBlockProps) {
  const data = props.content ?? props;
  const ds = props.displaySettings;
  const { pa } = getPreviewUtils(data as any);

  const isColor = props.displayTemplateKey === "LogoGridColorTemplate";
  const showNames = ds?.showNames === true;
  const sizeKey = (ds?.size as string) || "default";
  const { wrapper: logoWrapper, imgSizes } = LOGO_SIZES[sizeKey] ?? LOGO_SIZES["default"];

  const alignKey = (ds?.textAlign as string) || "center";
  const textAlignClass = TEXT_ALIGN_CLASSES[alignKey] ?? "text-center";
  const flexAlignClass = FLEX_ALIGN[alignKey] ?? "justify-center";

  const logos = (data.logos ?? []).filter((l): l is LogoItem => l !== null);
  const showPlaceholders = logos.length === 0;

  return (
    <section data-component="LogoGridBlock" className={`py-20 px-8 max-w-7xl mx-auto ${textAlignClass}`}>
      {data.heading && (
        <h2
          {...pa("heading")}
          className="font-display text-2xl md:text-3xl font-extrabold text-on-surface mb-3"
        >
          {data.heading}
        </h2>
      )}
      {data.subheading && (
        <p
          {...pa("subheading")}
          className="text-sm text-on-surface-variant mb-12 max-w-xl mx-auto"
        >
          {data.subheading}
        </p>
      )}

      <div className={`flex flex-wrap items-center gap-8 ${flexAlignClass}`}>
        {showPlaceholders
          ? Array.from({ length: PLACEHOLDER_COUNT }).map((_, i) => (
              <div
                key={i}
                className={`${logoWrapper} rounded-xl bg-surface-low flex items-center justify-center text-xs text-on-surface-variant opacity-50`}
              >
                Logo {i + 1}
              </div>
            ))
          : logos.map((logo, i) => {
              const src  = logo._metadata?.url?.default;
              const name = logo._metadata?.displayName ?? `Partner ${i + 1}`;
              if (!src) return null;
              return (
                <div key={i} className="flex flex-col items-center gap-1.5">
                  <div
                    className={`relative ${logoWrapper} ${isColor ? "" : "grayscale opacity-70"} hover:grayscale-0 transition-all duration-300 hover:opacity-100`}
                  >
                    <Image
                      src={src}
                      alt={name}
                      fill
                      className="object-contain"
                      sizes={imgSizes}
                    />
                  </div>
                  {showNames && (
                    <span className="text-xs text-on-surface-variant">{name}</span>
                  )}
                </div>
              );
            })}
      </div>
    </section>
  );
}
src/components/blocks/RenditionImageBlock/index.tsx
import { contentType, damAssets } from "@optimizely/cms-sdk";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";

export const RenditionImageBlockType = contentType({
  key: "RenditionImageBlock",
  displayName: "Rendition Image Block",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled", "elementEnabled"],
  properties: {
    // No indexingType: indexingType:"disabled" makes the SDK drop the reference
    // from its generated fragment, so the image (and its DAM renditions) is never
    // queried. Omit it so the SDK includes the field + DAM item expansion.
    image: {
      type: "contentReference",
      displayName: "Image",
      allowedTypes: ["_image"],
    },
    rendition: {
      type: "string",
      displayName: "Crop",
      enum: [
        { value: "portrait-crop",  displayName: "Portrait (cards, profile images)" },
        { value: "landscape-crop", displayName: "Landscape (heroes, banners)" },
      ],
    },
    altText: { type: "string", displayName: "Alt Text", isLocalized: true },
  },
});

interface RenditionImageBlockData {
  // DAM renditions live under image.item and are read by src()/getSrcset. The
  // url/_metadata fallbacks cover CMS globalassets that have no DAM item.
  image?: {
    url?: { default?: string | null } | null;
    _metadata?: { url?: { default?: string | null } | null } | null;
  } | null;
  rendition?: string | null;
  altText?: string | null;
}

type RenditionImageBlockProps = RenditionImageBlockData & {
  content?: RenditionImageBlockData;
};

export default function RenditionImageBlock(props: RenditionImageBlockProps) {
  const data = props.content ?? props;
  const { pa, src } = getPreviewUtils(data as any);
  const { getSrcset, getAlt } = damAssets(data as any);

  // src() resolves the DAM asset URL + preview token; fallbacks cover globalassets.
  const imageUrl =
    src(data.image as any) ??
    data.image?.url?.default ??
    data.image?._metadata?.url?.default;

  if (!imageUrl) return null;

  return (
    <figure data-component="RenditionImageBlock" className="max-w-7xl mx-auto px-8 py-8">
      <div {...pa("image")} className="overflow-hidden rounded-2xl">
        {/* eslint-disable-next-line @next/next/no-img-element -- DAM URLs carry a preview token; next/image would re-optimise and strip it */}
        <img
          src={imageUrl}
          srcSet={getSrcset(data.image as any)}
          sizes="(max-width: 1280px) 100vw, 1280px"
          alt={getAlt(data.image as any, data.altText ?? "")}
          className="w-full h-auto block"
        />
      </div>
    </figure>
  );
}