Auto-Generate Base64 Blur Placeholders in Payload CMS with Sharp

Create tiny blurDataURL placeholders at upload time and use them with Next.js Image

·Matija Žiberna·
Auto-Generate Base64 Blur Placeholders in Payload CMS with Sharp

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

I recently rebuilt a client's e-commerce site using Payload CMS and Next.js, and needed smooth image loading with blur placeholders. Coming from a Shopify project where I'd already solved runtime image optimization costs (detailed in my Shopify + Next.js image optimization guide), I knew the pattern: pre-generate image variants at upload time instead of letting Vercel charge you per optimization.

But there was one missing piece. While Payload CMS handles multiple image sizes automatically (thumbnail, card, tablet), the Next.js Image component's blur placeholder feature requires base64-encoded blur data. Unlike local imports where Next.js generates these automatically, remote CMS images don't get this treatment. You need to create the blur placeholders yourself.

This guide shows you exactly how to use Sharp to auto-generate tiny base64 blur placeholders in Payload CMS hooks. By the end, every image upload will automatically get a blur placeholder stored in your database, ready for Next.js to use.

Why Base64 Blur Placeholders Matter

The Next.js Image component has a brilliant blur placeholder feature that prevents layout shift and creates smooth loading transitions. When you use local images with static imports, Next.js generates these automatically at build time. But when your images come from a CMS, you're on your own.

You could generate blur images as separate files, but that adds HTTP requests and CDN costs. The elegant solution is base64 encoding: a tiny 10x10 pixel blur image encoded as a data URL and stored directly in your database. We're talking about 170 bytes per image - a 0.008% overhead that eliminates an entire class of loading issues.

Similar to how Shopify's CDN generates image variants automatically (which I used to bypass Vercel's image optimization charges), Payload CMS can generate these blur placeholders at upload time. The difference is that Payload gives you full control over the process through its hook system.

The Implementation Strategy

Payload CMS already handles image variant generation through its upload field configuration. You define sizes like thumbnail, card, and tablet, and Payload creates them automatically. We'll extend this with an afterChange hook that generates blur placeholders using Sharp.

Here's what happens on every image upload:

  1. Payload processes the upload and generates your configured image sizes
  2. Our afterChange hook triggers
  3. Sharp fetches the uploaded image, resizes it to 10x10 pixels, applies blur, and converts to PNG
  4. We encode the result as base64 and store it in the database
  5. Next.js receives the blur data with the image metadata (zero extra requests)

The entire process adds about 100-200ms per upload, and the blur placeholder lives in your database forever.

Setting Up the Collection Schema

First, add the blurDataURL field to your Media collection. This field stores the base64-encoded blur placeholder.

// File: src/collections/Media/index.ts
import { CollectionConfig } from "payload";

export const Media: CollectionConfig = {
  slug: "media",
  upload: {
    imageSizes: [
      {
        name: "thumbnail",
        width: 300,
        height: 300,
        position: "centre",
      },
      {
        name: "card",
        width: 640,
        height: 480,
      },
      {
        name: "tablet",
        width: 1024,
        height: undefined,
        position: "centre",
      },
    ],
    formatOptions: {
      format: "webp",
      options: {
        quality: 80,
      },
    },
  },
  fields: [
    {
      name: "alt",
      type: "text",
      label: "Alt Text",
    },
    {
      name: "blurDataURL",
      type: "text",
      label: "Blur Placeholder",
      admin: {
        description: "Base64 encoded blur placeholder (auto-generated)",
        readOnly: true,
      },
    },
  ],
};

This configuration tells Payload to convert uploads to WebP format at 80% quality and generate three size variants. The blurDataURL field is marked as read-only because our hook will populate it automatically. Users never interact with this field directly.

Creating the Blur Placeholder Hook

Now comes the core implementation. Create a hook that generates blur placeholders using Sharp whenever an image is uploaded.

// File: src/collections/Media/hooks/generateBlurPlaceholder.ts
import sharp from "sharp";
import { CollectionAfterChangeHook } from "payload";

export const generateBlurPlaceholder: CollectionAfterChangeHook = async ({
  doc,
  req,
  operation,
}) => {
  // Skip non-images and if blur already exists
  if (
    !doc.filename ||
    !doc.mimeType?.startsWith('image/') ||
    doc.blurDataURL
  ) {
    return doc;
  }

  try {
    req.payload.logger.info(`Generating blur placeholder for: ${doc.filename}`);

    if (!doc.url) {
      req.payload.logger.warn(`Skipping ${doc.filename} - no URL found`);
      return doc;
    }

    // Ensure we have a full URL for fetching
    const fullUrl = doc.url.startsWith('http')
      ? doc.url
      : `${process.env.NEXT_PUBLIC_SERVER_URL}${doc.url}`;

    // Fetch the uploaded image
    const imageBuffer = await fetchImageBuffer(fullUrl);

    // Generate 10x10 blur placeholder
    const blurBuffer = await sharp(imageBuffer)
      .resize(10, 10, { fit: 'inside' })
      .blur(1)
      .png({ quality: 20 })
      .toBuffer();

    // Convert to base64 data URL
    const base64 = `data:image/png;base64,${blurBuffer.toString('base64')}`;

    // Use setTimeout to avoid recursion (explained below)
    setTimeout(async () => {
      try {
        await req.payload.update({
          collection: 'media',
          id: doc.id,
          data: { blurDataURL: base64 } as any,
        });
        req.payload.logger.info(`✅ Blur placeholder saved for: ${doc.filename}`);
      } catch (delayedUpdateError) {
        req.payload.logger.error(`❌ Failed to save blur placeholder: ${delayedUpdateError.message}`);
      }
    }, 100);

    return doc;

  } catch (error) {
    req.payload.logger.error(`Failed to generate blur placeholder: ${error.message}`);
    return doc;
  }
};

async function fetchImageBuffer(imageUrl: string): Promise<Buffer> {
  const response = await fetch(imageUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
  }
  return Buffer.from(await response.arrayBuffer());
}

This hook does several things worth understanding. First, it skips processing if the document isn't an image or already has a blur placeholder. This prevents unnecessary work and ensures we don't regenerate placeholders on every save.

The Sharp processing chain is straightforward: resize to 10x10 pixels using the "inside" fit mode (maintains aspect ratio), apply a 1-pixel Gaussian blur, convert to PNG with aggressive compression, and output to a buffer. Sharp handles WebP, JPEG, PNG, and other formats automatically, so you don't need format-specific logic.

Why PNG instead of JPEG for the blur? At tiny sizes, PNG compresses better and produces fewer artifacts. A 10x10 PNG blur typically weighs 150-200 bytes, while JPEG would be similar or larger due to header overhead. PNG also supports transparency if you need it later.

The setTimeout Pattern for Hook Recursion

The setTimeout wrapper deserves explanation because it solves a non-obvious problem. When you call req.payload.update() inside an afterChange hook, you're trying to update the same document that's currently being saved. This creates a timing conflict - the original save hasn't finished committing to the database yet.

Calling update immediately can trigger a "NotFound" error or cause the hook to fire recursively. By delaying the update by 100 milliseconds using setTimeout, you allow the original save operation to complete before attempting your update.

I've written a detailed guide on this pattern and other safe data manipulation techniques in Payload CMS hooks that covers the underlying causes and alternative approaches: Payload CMS Hooks: Safe Data Manipulation with PostgreSQL. The short version is that setTimeout breaks the synchronous execution chain and prevents recursion issues.

The 100ms delay is imperceptible to users (they're already waiting for the upload to complete), and it's long enough for PostgreSQL to commit the transaction. In production, this pattern has proven reliable across thousands of image uploads.

Adding the Hook to Your Collection

Wire the hook into your Media collection's configuration.

// File: src/collections/Media/index.ts
import { CollectionConfig } from "payload";
import { generateBlurPlaceholder } from "./hooks/generateBlurPlaceholder";

export const Media: CollectionConfig = {
  slug: "media",
  hooks: {
    afterChange: [generateBlurPlaceholder],
  },
  // ... rest of configuration
};

If you already have other afterChange hooks, add the blur placeholder hook first in the array. This ensures it runs before hooks that might need the blur data.

Using Blur Placeholders in Next.js

Now that your images have blur placeholders, integrate them with Next.js Image components. Create a wrapper component that handles the blur data automatically.

// File: src/components/ui/PayloadImage.tsx
import Image from "next/image";
import { Media } from "@payload-types";

interface PayloadImageProps {
  image: Media;
  alt?: string;
  priority?: boolean;
  className?: string;
  sizes?: string;
}

export function PayloadImage({
  image,
  alt,
  priority = false,
  className,
  sizes,
}: PayloadImageProps) {
  if (!image || typeof image !== "object") {
    return null;
  }

  return (
    <Image
      src={image.url}
      alt={alt || image.alt || ""}
      width={800}
      height={600}
      sizes={sizes || "100vw"}
      priority={priority}
      unoptimized={true}
      placeholder={image.blurDataURL ? "blur" : "empty"}
      blurDataURL={image.blurDataURL || undefined}
      className={className}
    />
  );
}

The critical props here are unoptimized={true} and the blur configuration. Setting unoptimized to true tells Next.js to use your image URLs directly without runtime optimization. This is the same approach I used with Shopify's CDN - since Payload already generated optimized WebP variants at upload time, there's no need to pay Vercel for runtime processing.

The placeholder and blurDataURL props enable the blur effect. If a blur placeholder exists, Next.js shows it immediately while the full image loads. The transition is handled automatically.

Performance Characteristics

Let's look at the actual numbers from production use. A typical 2MB WebP image generates a blur placeholder of approximately 170 bytes as a PNG. Converting that to base64 adds minimal overhead (base64 is about 33% larger than binary), resulting in a final database value of around 250 characters.

The Sharp processing takes 100-200 milliseconds per image on a standard server. This happens once, at upload time, and the result is cached in your database forever. Compare this to runtime optimization where every image request could trigger processing.

Storage overhead is negligible. For 1000 images, you're storing an extra 170 KB of blur data alongside 2 GB of actual images - that's 0.0085% overhead. The database impact is similarly minimal since these are simple text fields.

The real performance win comes on the frontend. Because blur placeholders are inline base64 data URLs returned with the image metadata, there are zero additional HTTP requests. The moment your page loads and receives the image data from Payload's API, it has everything needed to show a blurred preview. No waiting for CDN requests, no layout shift, no orange flash.

Migration Considerations

If you're adding this to an existing Payload project in production, you'll need to use migrations rather than Payload's push mode. I won't detail the full migration workflow here (that's a separate topic), but the essential point is that you need to add the blurDataURL field through a proper migration.

The hook will only generate blur placeholders for new uploads. Existing images in your database won't automatically get them. You have two options: re-upload those images through the admin panel, or create a one-time script that loops through existing media and generates placeholders. For most projects, gradually re-uploading images as content is updated works fine.

Conclusion

Generating base64 blur placeholders in Payload CMS gives you the same smooth image loading experience that local Next.js imports provide, without the ongoing costs of runtime optimization. By leveraging Sharp in afterChange hooks, you process images once at upload time and store tiny placeholders directly in your database.

The pattern mirrors what I did with Shopify's CDN: pre-generate everything you need (image variants, blur data, optimized formats) and serve them as static assets. Vercel never touches your images, you avoid optimization charges, and users get a faster, smoother experience.

The setTimeout pattern for avoiding hook recursion is the only tricky part, and I've covered that in depth in my Payload CMS hooks guide. Once you understand that pattern, this implementation is straightforward.

Let me know in the comments if you have questions about the Sharp processing, the hook pattern, or integrating this with your Next.js setup. Subscribe for more practical Payload CMS and Next.js guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Auto-Generate Base64 Blur Placeholders in Payload CMS with Sharp | Build with Matija