A fast Crystallize storefront mostly comes from a few choices: images served straight from the Crystallize CDN at the right size, one high-priority image per page, cached Discovery reads, servers close to the API, and prefetching on intent. This page covers each, measured against Core Web Vitals.
Core Web Vitals are three measurements of what a shopper feels: LCP (Largest Contentful Paint, how soon the main content shows), INP (Interaction to Next Paint, how quickly the page responds to a tap or click) and CLS (Cumulative Layout Shift, how much things jump). Search engines use them as a ranking signal. On a storefront, LCP is usually the product or hero image, INP is the buy box and filters, and CLS is images and late banners.
Crystallize generates every image at a ladder of widths, in AVIF, WebP and (for JPEG and PNG sources) the original format, and serves them from its CDN. The work is done before the request. The storefront's job is to pick the right file.
next/image and other framework pipelines re-encode a file that is already encoded, add a hop and bill you for it. Point <img> straight at the Crystallize CDN.variants(types: "webp", maxWidth: 768) trims a listing's payload on the server.type CrystallizeImageData = { url: string; altText?: string | null; variants?: { url: string; width: number; height?: number | null }[] | null };
export function CrystallizeImage({ image, sizes, priority = false, hidden = false }: { image?: CrystallizeImageData | null; sizes: string; priority?: boolean; hidden?: boolean }) {
if (!image) return null;
// Variants have no format field: pick WebP by extension, one per width.
const webp = [...new Map((image.variants ?? []).filter((v) => v.url.endsWith('.webp')).map((v) => [v.width, v])).values()].sort((a, b) => a.width - b.width);
const largest = webp.at(-1);
return (
<img
src={largest?.url ?? image.url}
srcSet={webp.length ? webp.map((v) => `${v.url} ${v.width}w`).join(', ') : undefined}
sizes={sizes}
width={largest?.width}
height={largest?.height ?? undefined}
alt={image.altText ?? ''}
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : hidden ? 'low' : undefined}
decoding="async"
/>
);
}type CrystallizeImageData = { url: string; altText?: string | null; variants?: { url: string; width: number; height?: number | null }[] | null };
export function CrystallizeImage({ image, sizes, priority = false, hidden = false }: { image?: CrystallizeImageData | null; sizes: string; priority?: boolean; hidden?: boolean }) {
if (!image) return null;
// Variants have no format field: pick WebP by extension, one per width.
const webp = [...new Map((image.variants ?? []).filter((v) => v.url.endsWith('.webp')).map((v) => [v.width, v])).values()].sort((a, b) => a.width - b.width);
const largest = webp.at(-1);
return (
<img
src={largest?.url ?? image.url}
srcSet={webp.length ? webp.map((v) => `${v.url} ${v.width}w`).join(', ') : undefined}
sizes={sizes}
width={largest?.width}
height={largest?.height ?? undefined}
alt={image.altText ?? ''}
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : hidden ? 'low' : undefined}
decoding="async"
/>
);
}url is the original upload and is the fallback while renditions are still being generated. Variants are generated after upload, and Discovery captures them when the item is indexed. An item published before its renditions existed keeps an empty list until it is published again.
loading="eager" and fetchpriority="high", and every other image loading="lazy". Several "high" images compete with each other and nothing is faster.<main> can push the LCP image hundreds of kilobytes down. Stream the hero first, or preload it in <head> with <link rel="preload" as="image" imagesrcset imagesizes>.<link rel="preconnect" href="https://media.crystallize.com"> (or your custom media domain), so the connection is ready when the image is requested.<img> has width and height (from the variant), so the browser reserves the space before the file arrives.font-display: swap and a size-adjusted fallback, so text doesn't reflow when the font lands.fra1 on Vercel) instead of a US default cut every call's round trip.Lab tools show one device on one network. Measure real shoppers with the web-vitals library (its attribution build tells you which element was the LCP and which interaction was slow) and send the numbers to your own endpoint:
import { onCLS, onINP, onLCP } from 'web-vitals/attribution';
function send(metric: { name: string; value: number; id: string; rating: string }) {
navigator.sendBeacon('/api/vitals', JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, rating: metric.rating, path: location.pathname }));
}
onLCP(send);
onINP(send);
onCLS(send);import { onCLS, onINP, onLCP } from 'web-vitals/attribution';
function send(metric: { name: string; value: number; id: string; rating: string }) {
navigator.sendBeacon('/api/vitals', JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, rating: metric.rating, path: location.pathname }));
}
onLCP(send);
onINP(send);
onCLS(send);/favicon.ico. Browsers and crawlers ask for it, and without it the request can fall into a catch-all route.<meta name="color-scheme" content="light"> for a light-only design), so form controls and forced dark modes match the page.The Tools Universe and Photo Store demos serve images straight from the Crystallize CDN with WebP srcset, and prefetch product pages on hover. The full background on image formats and widths is in the responsive-images skill of the Crystallize agent skills.