Displaying images well is as important as uploading them. Lazy loading prevents images below the viewport from downloading until the user scrolls to them — critical for a post feed where there may be dozens of post thumbnails. A fallback for broken image URLs prevents the browser’s ugly broken image icon. Responsive images served at the right size for each device reduce data usage on mobile. A reusable Image component encapsulates all of these concerns so every image in the blog application benefits without repeating the implementation.
Reusable Image Component
// src/components/ui/Image.jsx
import { useState } from "react";
import PropTypes from "prop-types";
function Image({
src,
alt,
fallbackSrc = null,
className = "",
width,
height,
lazy = true, // default: lazy load
aspectRatio = null, // e.g. "16/9", "1/1", "4/3"
...rest
}) {
const [imgSrc, setImgSrc] = useState(src);
const [status, setStatus] = useState("loading"); // "loading" | "loaded" | "error"
function handleLoad() {
setStatus("loaded");
}
function handleError() {
setStatus("error");
if (fallbackSrc && imgSrc !== fallbackSrc) {
setImgSrc(fallbackSrc); // try fallback URL
} else {
setImgSrc(null); // no fallback — show placeholder
}
}
// Skeleton placeholder while loading or on persistent error
if (!imgSrc) {
return (
<div
className={`bg-gray-200 flex items-center justify-center ${className}`}
style={aspectRatio ? { aspectRatio } : { width, height }}
aria-hidden="true"
>
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeWidth={1.5}
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5M12 6.75h.008v.008H12V6.75z" />
</svg>
</div>
);
}
return (
<div className={`relative overflow-hidden ${className}`}
style={aspectRatio ? { aspectRatio } : undefined}>
{/* Skeleton shown while loading */}
{status === "loading" && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
<img
src={imgSrc}
alt={alt}
loading={lazy ? "lazy" : "eager"}
width={width}
height={height}
onLoad={handleLoad}
onError={handleError}
className={`w-full h-full object-cover transition-opacity duration-200
${status === "loaded" ? "opacity-100" : "opacity-0"}`}
{...rest}
/>
</div>
);
}
Image.propTypes = {
src: PropTypes.string,
alt: PropTypes.string.isRequired,
fallbackSrc: PropTypes.string,
className: PropTypes.string,
lazy: PropTypes.bool,
aspectRatio: PropTypes.string,
};
export default Image;
// Usage:
// <Image src={post.cover_image_url} alt={post.title}
// aspectRatio="16/9" className="rounded-lg"
// fallbackSrc="/images/post-placeholder.jpg" />
Note: The
loading="lazy" attribute is a native browser feature — the browser only downloads the image when it is about to enter the viewport (typically within ~1 screen height). This is zero-JavaScript, works in all modern browsers, and can dramatically reduce the data usage and initial page load time for a post feed. Use loading="eager" for the hero image above the fold (the first visible image on the page), since lazy loading the LCP (Largest Contentful Paint) element hurts Core Web Vitals.Tip: Set explicit
width and height attributes on images whenever possible. This allows the browser to allocate the correct space in the layout before the image loads, preventing “cumulative layout shift” (CLS) — the jarring effect where text jumps down when an image loads and expands the page. With the aspectRatio prop approach in the Image component, the container reserves the correct space using CSS, achieving the same effect without needing exact pixel dimensions.Warning: The
onError handler on an <img> element can cause an infinite loop if the fallback image also fails to load. The fallback image (fallbackSrc) triggers another onError, which tries to load the fallback again, which fails, which triggers onError… The guard if (fallbackSrc && imgSrc !== fallbackSrc) prevents this: once the fallback is set as the src, a subsequent error sets src to null (showing the placeholder) instead of retrying the fallback.Responsive Images with srcSet
// If your FastAPI backend generates multiple sizes (original, medium, thumbnail):
function ResponsivePostImage({ post }) {
if (!post.cover_image_url) return null;
// Assume FastAPI serves: /uploads/posts/abc.webp (original)
// /uploads/posts/abc_medium.webp (800px)
// /uploads/posts/abc_thumb.webp (400px)
const base = post.cover_image_url.replace(".webp", "");
return (
<img
src={post.cover_image_url} // fallback for browsers without srcset
srcSet={`
${base}_thumb.webp 400w,
${base}_medium.webp 800w,
${post.cover_image_url} 1200w
`}
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
alt={post.title}
loading="lazy"
className="w-full h-48 object-cover rounded-t-xl"
/>
);
}
Common Mistakes
Mistake 1 — Lazy loading the LCP image (Core Web Vitals issue)
❌ Wrong — the hero image loads late, harming Largest Contentful Paint:
<Image src={heroImageUrl} loading="lazy" /> // LCP image should be eager!
✅ Correct:
<Image src={heroImageUrl} lazy={false} /> // ✓ eager for above-the-fold
Mistake 2 — onError infinite loop without guard
❌ Wrong — fallback also fails, loops forever.
✅ Correct — guard: only try fallback if current src is not already the fallback.
Quick Reference
| Feature | HTML / Code |
|---|---|
| Lazy load | <img loading="lazy" /> |
| Error fallback | onError={(e) => e.target.src = fallback} |
| Responsive sizes | srcSet="small.webp 400w, large.webp 800w" |
| Prevent layout shift | Set width/height or aspect-ratio CSS |
| Loading skeleton | Absolute-positioned div shown until onLoad fires |
| Eager (LCP) | <img loading="eager" /> for hero/above-fold images |