Build a High-Performance Payload Image Component in Next.js
Use a custom Next.js loader and split Server/Client components to resolve Payload image optimization conflicts and…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Moving from a standard static site to a CMS-backed architecture in Next.js is usually straightforward, until you hit image optimization. I recently had to integrate Payload CMS with the Next.js App Router, and I hit a common wall.
Next.js has a powerful Image Optimization API, and Payload has its own image resizing engine. If you aren't careful, you end up with a conflict. You either let Next.js re-process every image on the server (high CPU usage), or you disable optimization and serve massive original files to mobile devices.
To make matters worse, trying to fetch image data by ID inside an interactive component (like a slider) often triggers the dreaded async/await errors in Client Components.
Here is the step-by-step process I developed to solve both problems using a custom loader and a "split" component architecture.
The Strategy: The "Magic" Loader
The secret to efficient images with Payload is realizing that Payload has already done the hard work. When you upload an image, Payload generates sizes like thumbnail, card, and tablet.
We don't need Next.js to resize the image again. We just need Next.js to calculate which size the browser needs and point to the correct Payload URL.
We achieve this via a custom loader function. This allows us to keep unoptimized={false} (so Next.js generates a responsive srcset) while completely bypassing the Next.js server processing.
Step 1: The Client Renderer
First, we build the Client Component. This component’s only job is to render the image. It contains our custom loader logic, handles focal points, and manages the blur placeholder.
Create this file at src/components/payload/images/payload-image-client.tsx. Note the "use client" directive at the top.
// File: src/components/payload/images/payload-image-client.tsx
"use client";
import React from "react";
import Image, { ImageLoaderProps, ImageProps } from "next/image";
import { Media } from "@payload-types";
import { cn } from "@/lib/utils";
import getImageAlt from "@/payload/utilities/images/getImageAlt";
import { isObject } from "@/lib/type-guards";
export type ImageContext = "thumbnail" | "card" | "hero" | "full";
export interface PayloadImageClientProps extends Omit<ImageProps, "src" | "alt"> {
image: Media | string | null | undefined;
context?: ImageContext;
}
export const PayloadImageClient = ({
image,
context = "card",
className,
fill = false,
priority = false,
...props
}: PayloadImageClientProps) => {
// Handle string URLs (external or hardcoded placeholders)
if (typeof image === "string") {
return (
<Image
src={image}
alt={props.title || "image"}
fill={fill}
width={!fill ? 800 : undefined}
height={!fill ? 600 : undefined}
className={cn(className)}
{...props}
/>
);
}
// Ensure we have a valid Media object
if (!isObject<Media>(image)) return null;
// THE MAGIC LOADER
// This maps the width Next.js requests to your specific Payload sizes
const payloadLoader = ({ width }: ImageLoaderProps): string => {
const sizes = image.sizes;
const originalUrl = image.url || "";
if (!sizes) return originalUrl;
// 1. If Next asks for small (<= 300px), give 'thumbnail'
if (width <= 300 && sizes.thumbnail?.url) return sizes.thumbnail.url;
// 2. If Next asks for medium (<= 600px), give 'card'
if (width <= 600 && sizes.card?.url) return sizes.card.url;
// 3. If Next asks for large (<= 1024px), give 'tablet'
if (width <= 1024 && sizes.tablet?.url) return sizes.tablet.url;
// 4. Fallback to Original for huge screens
return originalUrl;
};
// Define how the image behaves in different contexts
const getSizesAttr = () => {
if (props.sizes) return props.sizes;
switch (context) {
case "thumbnail": return "300px";
case "hero":
case "full": return "100vw";
case "card":
default: return "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
}
};
// Handle Focal Point cropping
const objectPosition =
typeof image.focalX === "number" && typeof image.focalY === "number"
? `${Math.round(image.focalX * 100)}% ${Math.round(image.focalY * 100)}%`
: undefined;
const altText = getImageAlt(image);
return (
<Image
loader={payloadLoader}
src={image.url || ""}
alt={altText}
fill={fill}
width={!fill ? image.width || 800 : undefined}
height={!fill ? image.height || 600 : undefined}
sizes={getSizesAttr()}
priority={priority}
placeholder={image.blurDataURL ? "blur" : "empty"}
blurDataURL={image.blurDataURL || undefined}
className={cn(className)}
style={{
objectPosition,
...props.style,
}}
{...props}
/>
);
};
This component is "dumb"—it doesn't know how to fetch data. It simply takes a populated Media object and renders it efficiently. We also handle the sizes prop here, defaulting to a sensible 3-column grid layout for cards unless specified otherwise.
Step 2: The Server Fetcher
The second piece of the puzzle is handling data fetching. In the App Router, we want to fetch data on the server whenever possible.
We create a Server Component wrapper that can accept an image ID (number), fetch the data from Payload, and then pass it down to our client renderer. We use server-only to ensure this heavy lifting never accidentally leaks into the client bundle.
// File: src/components/payload/images/payload-image.tsx
import "server-only"; // Prevents client-side usage errors
import React from "react";
import { Media } from "@payload-types";
import { cn } from "@/lib/utils";
import { getMediaObjectAuto } from "@/payload/utilities/images/getMediaObject";
import { PayloadImageClient, PayloadImageClientProps } from "./payload-image-client";
// Re-export types for cleaner imports
export type { ImageContext } from "./payload-image-client";
interface PayloadImageProps extends Omit<PayloadImageClientProps, "image"> {
image: Media | number | string | null | undefined;
tenantId?: string | number;
}
export const PayloadImage = async ({
image,
tenantId,
className,
...props
}: PayloadImageProps) => {
if (!image) return null;
// If a Number ID is passed, fetch the data on the server
if (typeof image === "number") {
if (!tenantId) {
// Error handling for missing context
return <div className="bg-gray-200 p-4 text-xs">Missing tenantId</div>;
}
const mediaObject = await getMediaObjectAuto(image, tenantId);
if (!mediaObject) {
return <div className="bg-gray-200 p-4 text-xs">Image Not Found</div>;
}
// Swap the ID for the fetched Object
image = mediaObject as Media;
}
// Pass resolved data to the Client Component
return (
<PayloadImageClient
image={image as Media | string}
className={className}
{...props}
/>
);
};
export default PayloadImage;
Step 3: Simplifying Imports
To keep our project clean, I always recommend a barrel file. This allows you to import either the server component or the client component from the same directory path.
// File: src/components/payload/images/index.ts
export { PayloadImage } from "./payload-image";
export { PayloadImageClient } from "./payload-image-client";
Implementation Guide
Now comes the most important part: knowing which component to use. Next.js enforces strict boundaries between Server and Client components.
Scenario A: Inside a Server Page
If you are in a page.tsx or layout.tsx, use the main <PayloadImage />. You can pass it a full object or just an ID, and it will handle the fetching automatically.
// src/app/page.tsx
import { PayloadImage } from "@/components/payload/images";
export default function Page() {
return (
// Passing an ID? No problem.
<PayloadImage image={12345} tenantId={1} context="card" />
);
}
Scenario B: Inside an Interactive Component
If you are building a carousel, slider, or any component with "use client", you cannot use the async Server Component. You must use <PayloadImageClient /> directly.
This means you must ensure the data is already populated (depth > 0) before passing it down.
// src/components/featured-industries.tsx
"use client";
import { PayloadImageClient } from "@/components/payload/images";
export const FeaturedIndustries = ({ data }) => {
return (
<div className="grid gap-4">
{data.industries.map((industry) => (
<div key={industry.id} className="relative h-64">
{/* Direct rendering only - no async fetching here */}
<PayloadImageClient
image={industry.image}
context="card"
className="object-cover rounded-lg"
/>
<h3>{industry.title}</h3>
</div>
))}
</div>
);
};
Summary
By separating our concerns, we've solved the optimization conflict. The PayloadImageClient handles the browser logic—mapping screen widths to Payload sizes without server overhead. The PayloadImage handles the database logic—fetching secure data and populating IDs.
This architecture gives you the best of both worlds: the performance of static assets with the flexibility of dynamic CMS data.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija